mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 09:07:00 +00:00
Compare commits
249 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0a7358ab | ||
|
|
d229b34d98 | ||
|
|
cbc3fe59a8 | ||
|
|
ab771cf76c | ||
|
|
7a27e09d23 | ||
|
|
cdce989a9c | ||
|
|
61dd3eddc5 | ||
|
|
290e48d875 | ||
|
|
e7ea94a5d2 | ||
|
|
43bd2a18ea | ||
|
|
ec95e58e13 | ||
|
|
70ac9c73ea | ||
|
|
0fcdcdd5f6 | ||
|
|
ea12a1ee56 | ||
|
|
9345ed60c6 | ||
|
|
0a13cf8304 | ||
|
|
4ebbdab7c0 | ||
|
|
cea9518b4b | ||
|
|
a9220277d6 | ||
|
|
bd45d9dffe | ||
|
|
baaf3a3a23 | ||
|
|
2e95a75d32 | ||
|
|
53d2296ff5 | ||
|
|
e8bf803ca0 | ||
|
|
d9dc000e89 | ||
|
|
205611856b | ||
|
|
5d396b9f25 | ||
|
|
4b95c6bda0 | ||
|
|
9982948c81 | ||
|
|
614b63cf28 | ||
|
|
b1027ca844 | ||
|
|
2176ad6ca2 | ||
|
|
971753e576 | ||
|
|
9053651cc1 | ||
|
|
a9593030ab | ||
|
|
75a7c1cfd4 | ||
|
|
699a534632 | ||
|
|
53f8d34961 | ||
|
|
81d09aabd1 | ||
|
|
11eec7db30 | ||
|
|
6e6482f6ad | ||
|
|
1efbbb353b | ||
|
|
b61fbe371a | ||
|
|
a2b6107dd6 | ||
|
|
f457412f98 | ||
|
|
14f1d75dba | ||
|
|
ce838dc054 | ||
|
|
0d29f3db1a | ||
|
|
cbc77530e9 | ||
|
|
70e8edf648 | ||
|
|
4368541a96 | ||
|
|
4d511bd29d | ||
|
|
b0894a8064 | ||
|
|
5d32d5190d | ||
|
|
b7154963c5 | ||
|
|
001e9de123 | ||
|
|
b64a5b7991 | ||
|
|
906a697542 | ||
|
|
46dbde04ae | ||
|
|
a31a7c3d2c | ||
|
|
675704ca91 | ||
|
|
d253d3164e | ||
|
|
ef3da383da | ||
|
|
db6c2596a0 | ||
|
|
7349d838bb | ||
|
|
d8c6364622 | ||
|
|
df758d063a | ||
|
|
34da0e5042 | ||
|
|
4a92b99a53 | ||
|
|
b1dc121cdd | ||
|
|
e5c8ef9e8d | ||
|
|
c6695c2418 | ||
|
|
53bbf4c7dc | ||
|
|
0015300920 | ||
|
|
fa6a5d729f | ||
|
|
cc9fba7adf | ||
|
|
93665656cf | ||
|
|
d918fdb137 | ||
|
|
fd1346c5f4 | ||
|
|
388bea740b | ||
|
|
583df9ee1f | ||
|
|
8f05b97947 | ||
|
|
8bdd0cc635 | ||
|
|
a372e8150e | ||
|
|
2bc2660ad5 | ||
|
|
5d6aa7c48a | ||
|
|
997e041042 | ||
|
|
5c362c1430 | ||
|
|
9219b2d411 | ||
|
|
86abc7ea68 | ||
|
|
867dbf41d5 | ||
|
|
51e458ad57 | ||
|
|
d29a7d6218 | ||
|
|
f6a8de3215 | ||
|
|
4e2e59ec87 | ||
|
|
6aeebb9824 | ||
|
|
a426f64795 | ||
|
|
b228c9477e | ||
|
|
d70f2fd196 | ||
|
|
0da89d91dd | ||
|
|
edab9e1b6b | ||
|
|
66aead387e | ||
|
|
efe1ac732e | ||
|
|
33dcd489eb | ||
|
|
6b2e5b2e41 | ||
|
|
812c9b99d1 | ||
|
|
8202c94a43 | ||
|
|
c1d4a73440 | ||
|
|
8e100ff21b | ||
|
|
088b772de5 | ||
|
|
faf8bd4a08 | ||
|
|
0e83a5a985 | ||
|
|
3ee91eb6c8 | ||
|
|
5cd0a6e2f3 | ||
|
|
fea733a43e | ||
|
|
d4e520772e | ||
|
|
e4a7212f89 | ||
|
|
e6a5fceedd | ||
|
|
bf4d5fbc6b | ||
|
|
93c3dec66e | ||
|
|
98026e0685 | ||
|
|
ecd3a97853 | ||
|
|
695270e515 | ||
|
|
43403bc6f7 | ||
|
|
6dbdbf1637 | ||
|
|
3c81e152e6 | ||
|
|
9501b460c5 | ||
|
|
6233cb1e07 | ||
|
|
f64f377199 | ||
|
|
f872424526 | ||
|
|
5d530edfab | ||
|
|
12c54f4bb3 | ||
|
|
23e47c567a | ||
|
|
b6940eccff | ||
|
|
eb796924b1 | ||
|
|
54ba59872e | ||
|
|
eff483c1c4 | ||
|
|
9f5d329105 | ||
|
|
77e6b88c5d | ||
|
|
5a9fd0686e | ||
|
|
3054dfe79e | ||
|
|
40cb76810e | ||
|
|
8b6b6640d5 | ||
|
|
a8945bd303 | ||
|
|
53e199b20f | ||
|
|
a6693481fa | ||
|
|
1aa58eeaaf | ||
|
|
133b36c34c | ||
|
|
ed28a4cc0d | ||
|
|
bc30b012cf | ||
|
|
2ae51c3f64 | ||
|
|
b6a87b9410 | ||
|
|
1f7dd0287a | ||
|
|
f33cbce63f | ||
|
|
79b6892320 | ||
|
|
799987ecb1 | ||
|
|
2d57839b3e | ||
|
|
86cc237c78 | ||
|
|
cc15ada304 | ||
|
|
49bc62f0aa | ||
|
|
444b65d371 | ||
|
|
15859f7be9 | ||
|
|
486388a798 | ||
|
|
9ab28d606a | ||
|
|
18b5ce8c18 | ||
|
|
93d5289d25 | ||
|
|
97bf9c257c | ||
|
|
18b0f5b790 | ||
|
|
94feb2ccaa | ||
|
|
aba3c46eb1 | ||
|
|
c9c910ab7c | ||
|
|
29c7295d16 | ||
|
|
61e15e4155 | ||
|
|
e03618570d | ||
|
|
d4cf95363f | ||
|
|
f260495495 | ||
|
|
d9f1f88107 | ||
|
|
09b704bcd7 | ||
|
|
a14cdce07f | ||
|
|
9fc5318e86 | ||
|
|
8affa0f375 | ||
|
|
cf8994ceaf | ||
|
|
39132723db | ||
|
|
642487742c | ||
|
|
544f0175d9 | ||
|
|
788e390e01 | ||
|
|
f6ae6bbdbb | ||
|
|
1155443785 | ||
|
|
056bef7d5e | ||
|
|
37eec298d7 | ||
|
|
a77b532328 | ||
|
|
00d6463de1 | ||
|
|
a3b92711a9 | ||
|
|
ba8c4cd2aa | ||
|
|
ec5267f5a5 | ||
|
|
73d2b1ba93 | ||
|
|
56fdaa1224 | ||
|
|
25090aeb2a | ||
|
|
9bc8d005fb | ||
|
|
b57e340389 | ||
|
|
b9043b6c39 | ||
|
|
5860171002 | ||
|
|
ad49bf2898 | ||
|
|
2d221a6b67 | ||
|
|
4f266cd3f3 | ||
|
|
9fc7202552 | ||
|
|
22a476ded5 | ||
|
|
54d3f6e3ad | ||
|
|
cbe61e3f2e | ||
|
|
3b65a8852e | ||
|
|
970a534d77 | ||
|
|
f7502b1c14 | ||
|
|
e0f7d88d61 | ||
|
|
fc8148bfb3 | ||
|
|
74fe6d55b4 | ||
|
|
47376d4db9 | ||
|
|
4b9b60f247 | ||
|
|
123b0f19db | ||
|
|
9fed08245a | ||
|
|
f807c166f7 | ||
|
|
9d257dd3c0 | ||
|
|
f74bb70499 | ||
|
|
802f6f5672 | ||
|
|
19966fad81 | ||
|
|
48db1437b3 | ||
|
|
1df270bab3 | ||
|
|
6fe1bdb579 | ||
|
|
9a27f33079 | ||
|
|
e363ece5a0 | ||
|
|
86d73f9118 | ||
|
|
bd87dcabf6 | ||
|
|
8019d3e0e2 | ||
|
|
8866720631 | ||
|
|
8f474bc313 | ||
|
|
3103b50f08 | ||
|
|
8d308a6776 | ||
|
|
00d254d7c4 | ||
|
|
2944c2a32f | ||
|
|
41c7c2a93a | ||
|
|
154b234205 | ||
|
|
ad1037c02b | ||
|
|
4b707537b9 | ||
|
|
bca7bec867 | ||
|
|
d15b3eb05e | ||
|
|
72709acb90 | ||
|
|
83f289eb40 | ||
|
|
7fd73a6fdb | ||
|
|
3bbc122869 | ||
|
|
55fd56a4a3 |
47
.chglog/CHANGELOG.tpl.md
Executable file
47
.chglog/CHANGELOG.tpl.md
Executable file
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
{{ if .Versions -}}
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
## [Unreleased]
|
||||
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
{{ range .Unreleased.CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .Versions }}
|
||||
{{- 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 }}
|
||||
|
||||
{{- if .MergeCommits -}}
|
||||
### Pull Requests
|
||||
{{ range .MergeCommits -}}
|
||||
- {{ .Header }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
12
.chglog/RELEASE.tpl.md
Executable file
12
.chglog/RELEASE.tpl.md
Executable file
@@ -0,0 +1,12 @@
|
||||
{{ if .Versions -}}
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
28
.chglog/config.yml
Executable file
28
.chglog/config.yml
Executable file
@@ -0,0 +1,28 @@
|
||||
style: github
|
||||
template: CHANGELOG.tpl.md
|
||||
info:
|
||||
title: CHANGELOG
|
||||
repository_url: https://github.com/axllent/mailpit
|
||||
options:
|
||||
commits:
|
||||
# filters:
|
||||
# Type:
|
||||
# - feat
|
||||
# - fix
|
||||
# - perf
|
||||
# - refactor
|
||||
commit_groups:
|
||||
title_maps:
|
||||
feature: Feature
|
||||
fix: Fix
|
||||
# perf: Performance Improvements
|
||||
# refactor: Code Refactoring
|
||||
header:
|
||||
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
|
||||
pattern_maps:
|
||||
- Type
|
||||
- Scope
|
||||
- Subject
|
||||
notes:
|
||||
keywords:
|
||||
- BREAKING CHANGE
|
||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/mailpit
|
||||
33
.github/workflows/build-docker.yml
vendored
Normal file
33
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
name: Build docker images
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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/386,linux/amd64,linux/arm,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=${{ github.ref_name }}"
|
||||
push: true
|
||||
tags: axllent/mailpit:latest,axllent/mailpit:${{ github.ref_name }}
|
||||
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "develop" ]
|
||||
schedule:
|
||||
- cron: '34 23 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
19
.github/workflows/release-build.yml
vendored
19
.github/workflows/release-build.yml
vendored
@@ -10,24 +10,25 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin]
|
||||
goarch: ["386", amd64, arm64]
|
||||
goarch: ["386", amd64, arm, arm64]
|
||||
exclude:
|
||||
- goarch: "386"
|
||||
goos: darwin
|
||||
- goarch: arm64
|
||||
- goarch: "386"
|
||||
goos: windows
|
||||
- goarch: arm
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Get tag
|
||||
id: tag
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: echo "Building assets for ${{ github.ref_name }}"
|
||||
- run: npm install
|
||||
- run: npm run package
|
||||
|
||||
@@ -38,7 +39,9 @@ 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
|
||||
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"
|
||||
overwrite: true
|
||||
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Test
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
push:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
@@ -16,13 +16,21 @@ jobs:
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- uses: actions/checkout@v3
|
||||
- run: go test ./storage -v
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test ./storage ./server -v
|
||||
- run: go test ./storage -bench=.
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm run package
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,5 +2,6 @@
|
||||
/send
|
||||
/server/ui/dist
|
||||
/Makefile
|
||||
/mailpit
|
||||
/mailpit*
|
||||
*.old
|
||||
*.db
|
||||
|
||||
295
CHANGELOG.md
295
CHANGELOG.md
@@ -1,15 +1,298 @@
|
||||
# Changelog
|
||||
|
||||
## [0.0.3]
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
- Bugfix: Update to clover-v2.0.0-alpha.2 to fix sorting
|
||||
## v1.2.7
|
||||
|
||||
### Feature
|
||||
- Allow custom webroot
|
||||
|
||||
|
||||
## [0.0.2]
|
||||
## v1.2.6
|
||||
|
||||
- Unread message statistics & updates
|
||||
### API
|
||||
- Provide structs of API v1 responses for use in client code
|
||||
|
||||
### Libs
|
||||
- Update go modules
|
||||
- Update node modules
|
||||
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### UI
|
||||
- Broadcast "delete all" action to reload all connected clients
|
||||
- Load first page if paginated list returns 0 results
|
||||
- Theme changes
|
||||
- Bump build action to use node 18
|
||||
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Bugfix
|
||||
- Fix mail download link
|
||||
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### API
|
||||
- Add limit and start parameters to search
|
||||
|
||||
### UI
|
||||
- Prevent double message index request on websocket connect
|
||||
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### API
|
||||
- Add API endpoint to return message headers
|
||||
|
||||
### Libs
|
||||
- Update go modules
|
||||
|
||||
### Testing
|
||||
- Add API test for raw & message headers
|
||||
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### UI
|
||||
- Update frontend modules
|
||||
- Add about app modal with version update notification
|
||||
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Feature
|
||||
- Add REST API
|
||||
|
||||
### Testing
|
||||
- Add API tests
|
||||
|
||||
### UI
|
||||
- Changes to use new data API
|
||||
- Hide delete all / mark all read in message view
|
||||
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Fix
|
||||
- Normalize running binary name detection (Windows)
|
||||
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Fix
|
||||
- Workaround for Safari source matching bug blocking event listener
|
||||
|
||||
### UI
|
||||
- Add documentation link (wiki)
|
||||
|
||||
|
||||
## 1.1.5
|
||||
|
||||
### Build
|
||||
- Switch to esbuild-sass-plugin
|
||||
|
||||
### UI
|
||||
- Support for inline images using filenames instead of cid
|
||||
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### Feature
|
||||
- Add --quiet flag to display only errors
|
||||
|
||||
### Security
|
||||
- Add restrictive HTTP Content-Security-Policy
|
||||
|
||||
### UI
|
||||
- Minor UI color change & unread count position adjustment
|
||||
- Add favicon unread message counter
|
||||
- Remove left & right borders (message list)
|
||||
|
||||
|
||||
## 1.1.3
|
||||
|
||||
### Fix
|
||||
- Update message download link
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
### Bugfix
|
||||
- Update to clover-v2.0.0-alpha.2 to fix sorting
|
||||
|
||||
### Tests
|
||||
- Add search tests
|
||||
|
||||
### UI
|
||||
- Add date to console log
|
||||
- Add space in To fields
|
||||
- Cater for messages without From email address
|
||||
- Minor UI & logging changes
|
||||
- Add space in To fields
|
||||
- cater for messages without From email address
|
||||
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Bugfix
|
||||
- Update to clover-v2.0.0-alpha.2 to fix sorting
|
||||
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Feature
|
||||
- Unread statistics
|
||||
|
||||
|
||||
## [0.0.1-beta]
|
||||
|
||||
- First release
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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/config.Version=${VERSION}" -o /mailpit
|
||||
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
COPY --from=builder /mailpit /mailpit
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
@@ -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"
|
||||
```
|
||||
56
README.md
56
README.md
@@ -1,38 +1,52 @@
|
||||
# Mailpit
|
||||
# Mailpit - email testing for developers
|
||||
|
||||
Mailpit is an email testing tool for developers.
|
||||

|
||||

|
||||

|
||||

|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
|
||||
## 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`)
|
||||
- Web UI to view emails (formatted HTML, highlighted HTML source, text, raw source and MIME attachments including image thumbnails)
|
||||
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
|
||||
- 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))
|
||||
- A simple REST API allowing ([see docs](docs/apiv1/README.md))
|
||||
- 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).
|
||||
|
||||
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
|
||||
|
||||
|
||||
### Configuring sendmail
|
||||
@@ -44,17 +58,17 @@ 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?
|
||||
|
||||
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
|
||||
|
||||
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
|
||||
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects) and has too many unnecessary features for my purpose. It performs exceptionally poorly when dealing with large amounts of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute to ingest). The API also transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
|
||||
|
||||
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.
|
||||
|
||||
91
cmd/root.go
91
cmd/root.go
@@ -1,14 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/server"
|
||||
"github.com/axllent/mailpit/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -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,77 @@ 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")
|
||||
}
|
||||
if len(os.Getenv("MP_WEBROOT")) > 0 {
|
||||
config.Webroot = os.Getenv("MP_WEBROOT")
|
||||
}
|
||||
|
||||
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.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
|
||||
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.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -5,21 +5,11 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/updater"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
)
|
||||
|
||||
// versionCmd represents the version command
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
@@ -36,10 +26,10 @@ var versionCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
||||
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, Version) {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, config.Version) {
|
||||
fmt.Printf(
|
||||
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
||||
latest,
|
||||
@@ -59,7 +49,7 @@ func init() {
|
||||
}
|
||||
|
||||
func updateApp() error {
|
||||
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
|
||||
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
148
config/config.go
148
config/config.go
@@ -2,7 +2,14 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tg123/go-htpasswd"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -12,8 +19,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 +28,58 @@ var (
|
||||
// VerboseLogging for console output
|
||||
VerboseLogging = false
|
||||
|
||||
// NoLogging for testing
|
||||
// QuietLogging for console output (errors only)
|
||||
QuietLogging = false
|
||||
|
||||
// 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
|
||||
|
||||
// Webroot to define the base path for the UI and API
|
||||
Webroot = "/"
|
||||
|
||||
// SMTPSSLCert file
|
||||
SMTPSSLCert string
|
||||
|
||||
// SMTPSSLKey file
|
||||
SMTPSSLKey string
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
|
||||
// SMTPAuth used for euthentication
|
||||
SMTPAuth *htpasswd.File
|
||||
|
||||
// ContentSecurityPolicy for HTTP server
|
||||
ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';"
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
)
|
||||
|
||||
// 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 +88,91 @@ 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
|
||||
}
|
||||
|
||||
if strings.Contains(Webroot, " ") {
|
||||
return fmt.Errorf("Webroot cannot contain spaces (%s)", Webroot)
|
||||
}
|
||||
|
||||
s, err := url.JoinPath("/", Webroot, "/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Webroot = s
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package data
|
||||
|
||||
import "time"
|
||||
|
||||
// MailboxSummary struct
|
||||
type MailboxSummary struct {
|
||||
Name string
|
||||
Slug string
|
||||
Total int
|
||||
Unread int
|
||||
LastMessage time.Time
|
||||
}
|
||||
|
||||
// WebsocketNotification struct for responses
|
||||
type WebsocketNotification struct {
|
||||
Type string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total int
|
||||
Unread int
|
||||
}
|
||||
115
docs/apiv1/Message.md
Normal file
115
docs/apiv1/Message.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Message
|
||||
|
||||
## Message summary
|
||||
|
||||
Returns a JSON summary of the message and attachments.
|
||||
|
||||
**URL** : `api/v1/message/<ID>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
## Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
|
||||
"Read": true,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": null,
|
||||
"Bcc": null,
|
||||
"Subject": "Message subject",
|
||||
"Date": "2016-09-07T16:46:00+13:00",
|
||||
"Text": "Plain text MIME part of the email",
|
||||
"HTML": "HTML MIME part (if exists)",
|
||||
"Size": 79499,
|
||||
"Inline": [
|
||||
{
|
||||
"PartID": "1.2",
|
||||
"FileName": "filename.gif",
|
||||
"ContentType": "image/gif",
|
||||
"ContentID": "919564503@07092006-1525",
|
||||
"Size": 7760
|
||||
}
|
||||
],
|
||||
"Attachments": [
|
||||
{
|
||||
"PartID": "2",
|
||||
"FileName": "filename.doc",
|
||||
"ContentType": "application/msword",
|
||||
"ContentID": "",
|
||||
"Size": 43520
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
### Notes
|
||||
|
||||
- `Read` - always true (message marked read on open)
|
||||
- `From` - Name & Address, or null
|
||||
- `To`, `CC`, `BCC` - Array of Names & Address, or null
|
||||
- `Date` - Parsed email local date & time from headers
|
||||
- `Size` - Total size of raw email
|
||||
- `Inline`, `Attachments` - Array of attachments and inline images.
|
||||
|
||||
|
||||
---
|
||||
## Attachments
|
||||
|
||||
**URL** : `api/v1/message/<ID>/part/<PartID>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the attachment using the MIME type provided by the attachment `ContentType`.
|
||||
|
||||
---
|
||||
## Headers
|
||||
|
||||
**URL** : `api/v1/message/<ID>/headers`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns all message headers as a JSON array.
|
||||
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": [
|
||||
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
|
||||
],
|
||||
"Date": [
|
||||
"Wed, 12 Jul 2006 23:38:30 +1200"
|
||||
],
|
||||
"Delivered-To": [
|
||||
"user@example.com",
|
||||
"user-alias@example.com"
|
||||
],
|
||||
"From": [
|
||||
"\"User Name\" \\u003remote@example.com\\u003e"
|
||||
],
|
||||
"Message-Id": [
|
||||
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
|
||||
],
|
||||
....
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
## Raw (source) email
|
||||
|
||||
**URL** : `api/v1/message/<ID>/raw`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
Returns the original email source including headers and attachments.
|
||||
166
docs/apiv1/Messages.md
Normal file
166
docs/apiv1/Messages.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Messages
|
||||
|
||||
List & delete messages.
|
||||
|
||||
|
||||
---
|
||||
## List
|
||||
|
||||
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
|
||||
### Query parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|----------------------------|
|
||||
| limit | integer | false | Limit results (default 50) |
|
||||
| start | integer | false | Pagination offset |
|
||||
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"count": 50,
|
||||
"start": 0,
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": [
|
||||
{
|
||||
"Name": "Accounts",
|
||||
"Address": "accounts@example.com"
|
||||
}
|
||||
],
|
||||
"Bcc": null,
|
||||
"Subject": "Message subject",
|
||||
"Created": "2022-10-03T21:35:32.228605299+13:00",
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `total` - Total messages in mailbox
|
||||
- `unread` - Total unread messages in mailbox
|
||||
- `count` - Number of messages returned in request
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `Read` - The read/unread status of the message
|
||||
- `From` - Name & Address, or null if none
|
||||
- `To`, `CC`, `BCC` - Array of Names & Address, or null if none
|
||||
- `Created` - Local date & time the message was received
|
||||
- `Size` - Total size of raw email in bytes
|
||||
|
||||
|
||||
---
|
||||
## Delete individual messages
|
||||
|
||||
Delete one or more messages by ID.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `DELETE`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
|
||||
---
|
||||
## Delete all messages
|
||||
|
||||
Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely).
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `DELETE`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": []
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
|
||||
---
|
||||
## Update individual read statuses
|
||||
|
||||
Set the read status of one or more messages.
|
||||
The `read` status can be `true` or `false`.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...],
|
||||
"read": false
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
---
|
||||
## Update all messages read status
|
||||
|
||||
Set the read status of all messages.
|
||||
The `read` status can be `true` or `false`.
|
||||
|
||||
**URL** : `api/v1/messages`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": [],
|
||||
"read": false
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
11
docs/apiv1/README.md
Normal file
11
docs/apiv1/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# API v1
|
||||
|
||||
Mailpit provides a simple REST API to access and delete stored messages.
|
||||
|
||||
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
|
||||
|
||||
The API is split into three main parts:
|
||||
|
||||
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
|
||||
- [Message](Message.md) - Return message data & attachments
|
||||
- [Search](Search.md) - Searching messages
|
||||
69
docs/apiv1/Search.md
Normal file
69
docs/apiv1/Search.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Search
|
||||
|
||||
**URL** : `api/v1/search?query=<string>`
|
||||
|
||||
**Method** : `GET`
|
||||
|
||||
The search returns the most recent matches (default 50).
|
||||
Matching messages are returned in the order of latest received to oldest.
|
||||
|
||||
|
||||
## Query parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|----------------------------|
|
||||
| query | string | true | Search query |
|
||||
| limit | integer | false | Limit results (default 50) |
|
||||
| start | integer | false | Pagination offset |
|
||||
|
||||
|
||||
## Response
|
||||
|
||||
**Status** : `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 500,
|
||||
"unread": 500,
|
||||
"count": 25,
|
||||
"start": 0,
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"Read": false,
|
||||
"From": {
|
||||
"Name": "John Doe",
|
||||
"Address": "john@example.com"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Name": "Jane Smith",
|
||||
"Address": "jane@example.com"
|
||||
}
|
||||
],
|
||||
"Cc": [
|
||||
{
|
||||
"Name": "Accounts",
|
||||
"Address": "accounts@example.com"
|
||||
}
|
||||
],
|
||||
"Bcc": null,
|
||||
"Subject": "Test email",
|
||||
"Created": "2022-10-03T21:35:32.228605299+13:00",
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `total` - Total messages in mailbox (all messages, not search)
|
||||
- `unread` - Total unread messages in mailbox (all messages, not search)
|
||||
- `count` - Number of messages returned in request
|
||||
- `start` - The offset (default `0`) for pagination
|
||||
- `From` - Singular Name & Address, or null if none
|
||||
- `To`, `CC`, `BCC` - Array of Name & Address, or null if none
|
||||
- `Size` - Total size of raw email in bytes
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,6 +1,6 @@
|
||||
const { build } = require('esbuild')
|
||||
const pluginVue = require('esbuild-plugin-vue-next')
|
||||
const sassPlugin = require("esbuild-plugin-sass");
|
||||
const { sassPlugin } = require('esbuild-sass-plugin');
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
|
||||
66
go.mod
66
go.mod
@@ -3,50 +3,60 @@ 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/jhillyerd/enmime v0.10.1
|
||||
github.com/k3a/html2text v1.0.8
|
||||
github.com/klauspost/compress v1.15.12
|
||||
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/cobra v1.6.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.0
|
||||
golang.org/x/text v0.4.0
|
||||
modernc.org/sqlite v1.19.3
|
||||
)
|
||||
|
||||
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-runewidth v0.0.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // 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-20220927061507-ef77025ab5aa // indirect
|
||||
github.com/rivo/uniseg v0.4.2 // 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.1.0 // indirect
|
||||
golang.org/x/image v0.1.0 // indirect
|
||||
golang.org/x/mod v0.6.0 // indirect
|
||||
golang.org/x/net v0.1.0 // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
golang.org/x/tools v0.2.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 // indirect
|
||||
modernc.org/libc v1.21.4 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
)
|
||||
|
||||
306
go.sum
306
go.sum
@@ -1,148 +1,109 @@
|
||||
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/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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
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=
|
||||
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
|
||||
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
|
||||
github.com/jhillyerd/enmime v0.10.1 h1:3VP8gFhK7R948YJBrna5bOgnTXEuPAoICo79kKkBKfA=
|
||||
github.com/jhillyerd/enmime v0.10.1/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
|
||||
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
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.12 h1:YClS/PImqYbn+UILDnqxQCZ3RehC9N318SU3kElDUEM=
|
||||
github.com/klauspost/compress v1.15.12/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
|
||||
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.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-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/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.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/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.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
|
||||
github.com/rivo/uniseg v0.4.2/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 +113,95 @@ 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/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
|
||||
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
|
||||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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/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/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
|
||||
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
|
||||
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
|
||||
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
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/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||
golang.org/x/sys v0.1.0/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/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
|
||||
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
|
||||
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.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 h1:0+dsXf0zeLx9ixj4nilg6jKe5Bg1ilzBwSFq4kJmIUc=
|
||||
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/libc v1.21.4 h1:CzTlumWeIbPV5/HVIMzYHNPCRP8uiU/CWiN2gtd/Qu8=
|
||||
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
|
||||
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.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.19.3 h1:dIoagx6yIQT3V/zOSeAyZ8OqQyEr17YTgETOXTZNJMA=
|
||||
modernc.org/sqlite v1.19.3/go.mod h1:xiyJD7FY8mTZXnQwE/gEL1STtFrrnDx03V8KhVQmcr8=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
|
||||
|
||||
98
install.sh
Normal file
98
install.sh
Normal 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"
|
||||
12
main.go
12
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/cmd"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
@@ -15,10 +16,19 @@ func main() {
|
||||
}
|
||||
|
||||
// running directly
|
||||
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked
|
||||
sendmail.Run()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns a lowercase string stripped of the file extension (if exists).
|
||||
// Used for detecting Windows commands which ignores letter casing and `.exe`.
|
||||
// eg: "MaIlpIT.Exe" returns "mailpit"
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
return strings.TrimSuffix(s, filepath.Ext(s))
|
||||
}
|
||||
|
||||
1857
package-lock.json
generated
1857
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,14 +12,15 @@
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"moment": "^2.29.4",
|
||||
"remove": "^0.1.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.14.50",
|
||||
"esbuild-plugin-sass": "^1.0.1",
|
||||
"esbuild-plugin-vue-next": "^0.1.4"
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^2.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -80,6 +80,6 @@ func Run() {
|
||||
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
log.Fatal(err)
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
224
server/api.go
224
server/api.go
@@ -1,224 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/data"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type messagesResult struct {
|
||||
Total int `json:"total"`
|
||||
Unread int `json:"unread"`
|
||||
Count int `json:"count"`
|
||||
Start int `json:"start"`
|
||||
Items []data.Summary `json:"items"`
|
||||
}
|
||||
|
||||
// Return a list of available mailboxes
|
||||
func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
|
||||
res, err := storage.ListMailboxes()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
if !storage.MailboxExists(mailbox) {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(mailbox, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet(mailbox)
|
||||
|
||||
var res messagesResult
|
||||
|
||||
res.Start = start
|
||||
res.Items = messages
|
||||
res.Count = len(res.Items)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
if !storage.MailboxExists(mailbox) {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
// we will only return up to 200 results
|
||||
start := 0
|
||||
limit := 200
|
||||
|
||||
messages, err := storage.Search(mailbox, search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet(mailbox)
|
||||
|
||||
var res messagesResult
|
||||
|
||||
res.Start = start
|
||||
res.Items = messages
|
||||
res.Count = len(messages)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
}
|
||||
|
||||
// Open a message
|
||||
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
}
|
||||
|
||||
// Download/view an attachment
|
||||
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
a, err := storage.GetAttachmentPart(mailbox, id, partID)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
w.Write(a.Content)
|
||||
}
|
||||
|
||||
// View the full email source as plain text
|
||||
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
data, err := storage.GetMessageRaw(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// Delete all messages in the mailbox
|
||||
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
err := storage.DeleteAllMessages(mailbox)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Delete a single message
|
||||
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
err := storage.DeleteOneMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
id := vars["id"]
|
||||
|
||||
err := storage.UnreadMessage(mailbox, id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
}
|
||||
271
server/apiv1/api.go
Normal file
271
server/apiv1/api.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Search returns up to 200 of the latest messages as JSON
|
||||
func Search(w http.ResponseWriter, r *http.Request) {
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.Search(search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = 0
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// GetMessage (method: GET) returns the *data.Message as JSON
|
||||
func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
httpError(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
func DownloadAttachment(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
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// Headers (method: GET) returns the message headers as JSON
|
||||
func Headers(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
reader := strings.NewReader(string(data))
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
headers := m.Header
|
||||
bytes, _ := json.Marshal(headers)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DownloadRaw (method: GET) returns the full email source as plain text
|
||||
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
|
||||
// If no IDs are provided then all messages are deleted.
|
||||
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var data struct {
|
||||
IDs []string
|
||||
}
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil || len(data.IDs) == 0 {
|
||||
if err := storage.DeleteAllMessages(); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, id := range data.IDs {
|
||||
if err := storage.DeleteOneMessage(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
|
||||
if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := storage.MarkAllUnread()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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"))
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "404 page not found")
|
||||
}
|
||||
|
||||
// HTTPError returns a basic error message (400 response)
|
||||
func httpError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
start = n
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
}
|
||||
52
server/apiv1/info.go
Normal file
52
server/apiv1/info.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/updater"
|
||||
)
|
||||
|
||||
type appVersion struct {
|
||||
Version string
|
||||
LatestVersion string
|
||||
Database string
|
||||
DatabaseSize int64
|
||||
Messages int
|
||||
Memory uint64
|
||||
}
|
||||
|
||||
// AppInfo returns some basic details about the running app, and latest release.
|
||||
func AppInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
info := appVersion{}
|
||||
info.Version = config.Version
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.Memory = m.Sys - m.HeapReleased
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
}
|
||||
|
||||
info.Database = config.DataFile
|
||||
|
||||
db, err := os.Stat(info.Database)
|
||||
if err == nil {
|
||||
info.DatabaseSize = db.Size()
|
||||
}
|
||||
|
||||
info.Messages = storage.CountTotal()
|
||||
|
||||
bytes, _ := json.Marshal(info)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
24
server/apiv1/structs.go
Normal file
24
server/apiv1/structs.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package apiv1
|
||||
|
||||
import "github.com/axllent/mailpit/storage"
|
||||
|
||||
// The following structs & aliases are provided for easy import
|
||||
// and understanding of the JSON structure.
|
||||
|
||||
// MessageSummary - summary of a single message
|
||||
type MessageSummary = storage.MessageSummary
|
||||
|
||||
// MessagesSummary - summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
Total int `json:"total"`
|
||||
Unread int `json:"unread"`
|
||||
Count int `json:"count"`
|
||||
Start int `json:"start"`
|
||||
Messages []MessageSummary `json:"messages"`
|
||||
}
|
||||
|
||||
// Message data
|
||||
type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
107
server/apiv1/thumbnails.go
Normal file
107
server/apiv1/thumbnails.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
thumbWidth = 180
|
||||
thumbHeight = 120
|
||||
)
|
||||
|
||||
// Thumbnail returns a thumbnail image for an attachment (images only)
|
||||
func Thumbnail(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())
|
||||
}
|
||||
155
server/server.go
155
server/server.go
@@ -3,18 +3,16 @@ package server
|
||||
import (
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -33,29 +31,58 @@ 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))))
|
||||
http.Handle("/", r)
|
||||
r := defaultRoutes()
|
||||
|
||||
if config.SSLCert != "" && config.SSLKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.SSLCert, config.SSLKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
// virtual filesystem for others
|
||||
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
|
||||
// redirect to webroot if no trailing slash
|
||||
if config.Webroot != "/" {
|
||||
redir := strings.TrimRight(config.Webroot, "/")
|
||||
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
||||
}
|
||||
|
||||
http.Handle("/", r)
|
||||
|
||||
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%s", config.HTTPListen, config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", config.HTTPListen, config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRoutes() *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
|
||||
// API V1
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.Headers)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// 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 +94,27 @@ 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) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
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 +127,27 @@ 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) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
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,37 +159,12 @@ func gzipHandler(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// FourOFour returns a standard 404 meesage
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "404 page not found")
|
||||
// Redirect to webroot
|
||||
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
}
|
||||
|
||||
// HTTPError returns a standard 404 meesage
|
||||
func httpError(w http.ResponseWriter, msg string) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
|
||||
start = int(n)
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 {
|
||||
if n > 500 {
|
||||
n = 500
|
||||
}
|
||||
limit = int(n)
|
||||
}
|
||||
|
||||
return start, limit
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
}
|
||||
|
||||
316
server/server_test.go
Normal file
316
server/server_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
putDataStruct struct {
|
||||
Read bool `json:"read"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
)
|
||||
|
||||
func Test_APIv1(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := defaultRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// store this for later tests
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10
|
||||
t.Log("Read first 10 messages including raw & headers")
|
||||
putIDS := []string{}
|
||||
for indx, msg := range m.Messages {
|
||||
if indx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// test RAW
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// test headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// mark first 10 as unread
|
||||
t.Log("Mark first 10 as unread")
|
||||
putData := putDataStruct
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark first 10 as read
|
||||
t.Log("Mark first 10 as read")
|
||||
putData.Read = true
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// search
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
|
||||
|
||||
// delete first 10
|
||||
t.Log("Delete first 10")
|
||||
_, err = clientDelete(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 90)
|
||||
|
||||
// mark all as read
|
||||
putData.Read = true
|
||||
putData.IDs = []string{}
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
t.Log("Mark all read")
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
|
||||
|
||||
// delete all
|
||||
t.Log("Delete all messages")
|
||||
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, received %s", err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
|
||||
}
|
||||
|
||||
func setup() {
|
||||
config.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
data, err := clientGet(uri)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, unread, m.Unread, "wrong unread count")
|
||||
assertEqual(t, total, m.Total, "wrong total count")
|
||||
}
|
||||
|
||||
func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
t.Logf("Test search: %s", query)
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
limit := fmt.Sprintf("%d", count)
|
||||
|
||||
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.Count, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
Subject(fmt.Sprintf("Subject line %d end", i)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := storage.Store(buf.Bytes()); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
|
||||
m := apiv1.MessagesSummary{}
|
||||
|
||||
data, err := clientGet(url)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func clientGet(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientDelete(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("DELETE", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPut(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("PUT", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,4 @@ import "./assets/styles.scss";
|
||||
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
|
||||
import "bootstrap";
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).mount('#app');
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
$link-decoration: none;
|
||||
$primary: #2c3e50;
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
// @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;
|
||||
transition: all 0.2s;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
span {
|
||||
opacity: 0.8;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +48,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 +67,7 @@
|
||||
margin: 15px 0 0;
|
||||
|
||||
th {
|
||||
padding-right: 10px;
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -52,3 +76,224 @@
|
||||
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: $gray-300;
|
||||
|
||||
.text-muted {
|
||||
color: $body-color !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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import axios from 'axios';
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
|
||||
// FakeModal is used to return a fake Bootstrap modal
|
||||
// if the ID returns nothing
|
||||
@@ -8,132 +10,205 @@ 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 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 object/array values
|
||||
* @params function callback function
|
||||
*/
|
||||
post: function (url, data, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.post(url, data)
|
||||
.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 object/array values
|
||||
* @params function callback function
|
||||
*/
|
||||
delete: function (url, data, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.delete(url, { data: data })
|
||||
.then(callback)
|
||||
.catch(self.handleError)
|
||||
.then(function () {
|
||||
// always executed
|
||||
if (self.loading > 0) {
|
||||
self.loading--;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Axios PUT request (REST only)
|
||||
*
|
||||
* @params string url
|
||||
* @params array object/array values
|
||||
* @params function callback function
|
||||
*/
|
||||
put: function (url, data, callback) {
|
||||
let self = this;
|
||||
self.loading++;
|
||||
axios.put(url, data)
|
||||
.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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default commonMixins
|
||||
export default commonMixins;
|
||||
|
||||
37
server/ui-src/templates/Attachments.vue
Normal file
37
server/ui-src/templates/Attachments.vue
Normal 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/v1/message/'+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/v1/message/'+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>
|
||||
@@ -1,71 +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/v1/message/' + self.message.ID + '/raw';
|
||||
});
|
||||
},
|
||||
|
||||
unmounted: function () {
|
||||
window.removeEventListener("resize", this.resizeIframes);
|
||||
},
|
||||
|
||||
methods: {
|
||||
resizeIframe: function(el) {
|
||||
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) {
|
||||
|
||||
messageDate: function (d) {
|
||||
return moment(d).format('ddd, D MMM YYYY, h:mm a');
|
||||
}
|
||||
}
|
||||
@@ -73,100 +108,120 @@ 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">
|
||||
{{ message.From.Name + " <" + message.From.Address +">" }}
|
||||
</span>
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td>
|
||||
<span v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Cc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.Bcc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td><strong>{{ message.Subject }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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"><{{ message.From.Address }}></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-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/v1/message/'+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>
|
||||
<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>
|
||||
<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" 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.HTML">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':''">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">
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
|
||||
seamless frameborder="0" style="width: 100%; height: 100%;">
|
||||
<iframe 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-plain-text" role="tabpanel"
|
||||
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
|
||||
{{ message.Text }}
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-source" role="tabpanel" aria-labelledby="nav-source-tab"
|
||||
tabindex="0">
|
||||
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
|
||||
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0" :class="message.HTML == '' ? 'show':''">
|
||||
<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-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 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>
|
||||
</div>
|
||||
|
||||
50
server/ui/favicon.svg
Normal file
50
server/ui/favicon.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="460"
|
||||
viewBox="0 0 132.292 121.708"
|
||||
version="1.1"
|
||||
id="svg6"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs10" />
|
||||
<sodipodi:namedview
|
||||
id="namedview8"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.80851684"
|
||||
inkscape:cx="401.9706"
|
||||
inkscape:cy="327.76064"
|
||||
inkscape:window-width="1554"
|
||||
inkscape:window-height="838"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6" />
|
||||
<path
|
||||
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
|
||||
fill-opacity=".941"
|
||||
fill="#2d4a5f"
|
||||
id="path2"
|
||||
style="fill:#415066;fill-opacity:1"
|
||||
inkscape:export-filename="/mnt/apache/sandpit/go/mailpit/server/ui/mailpit.png"
|
||||
inkscape:export-xdpi="12.29"
|
||||
inkscape:export-ydpi="12.29" />
|
||||
<path
|
||||
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
|
||||
fill="#00b786"
|
||||
id="path4"
|
||||
inkscape:export-filename="/mnt/apache/sandpit/go/mailpit/server/ui/mailpit.png"
|
||||
inkscape:export-xdpi="12.29"
|
||||
inkscape:export-ydpi="12.29" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<link rel="icon" href="mailpit.svg">
|
||||
<link rel="icon" href="favicon.svg">
|
||||
<title>Mailpit</title>
|
||||
<link rel=stylesheet href="dist/app.css">
|
||||
</head>
|
||||
|
||||
@@ -1,97 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="500"
|
||||
height="460"
|
||||
viewBox="0 0 132.29167 121.70833"
|
||||
viewBox="0 0 132.292 121.708"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
id="svg6"
|
||||
sodipodi:docname="mailpit.svg"
|
||||
inkscape:export-filename="/home/ralph/bitmap.png"
|
||||
inkscape:export-xdpi="176.09"
|
||||
inkscape:export-ydpi="176.09">
|
||||
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
id="defs10" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
id="namedview8"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.98994949"
|
||||
inkscape:cx="90.98717"
|
||||
inkscape:cy="229.51456"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer2"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
showguides="true"
|
||||
inkscape:guide-bbox="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
units="px"
|
||||
inkscape:window-width="1548"
|
||||
inkscape:zoom="0.80851684"
|
||||
inkscape:cx="401.35218"
|
||||
inkscape:cy="327.76064"
|
||||
inkscape:window-width="1554"
|
||||
inkscape:window-height="838"
|
||||
inkscape:window-x="52"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1">
|
||||
<sodipodi:guide
|
||||
position="39.014182,62.44412"
|
||||
orientation="0,1"
|
||||
id="guide4529"
|
||||
inkscape:locked="false" />
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
style="display:inline"
|
||||
transform="translate(-55.479864,-26.541592)">
|
||||
<g
|
||||
id="g4547"
|
||||
transform="matrix(1.9570423,0,0,1.9490788,-53.096581,-140.70068)"
|
||||
style="opacity:1">
|
||||
<path
|
||||
sodipodi:nodetypes="cccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4534"
|
||||
d="M 61.775483,85.805801 89.296873,113.46893 116.98363,85.8058 Z"
|
||||
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccccccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4540"
|
||||
d="m 58.113837,90.436008 31.088544,30.616072 31.277529,-30.521576 -0.0945,18.898806 -30.71057,12.56771 7.748511,6.47285 -4.157737,3.07105 -21.26116,0.0945 c -2.471939,-0.0114 -13.222442,-9.40933 -13.890627,-21.16666 z"
|
||||
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
|
||||
<path
|
||||
sodipodi:nodetypes="cccczzcccccc"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4542"
|
||||
d="m 95.532643,122.7713 27.544977,-11.12272 -4.10354,29.40775 -6.05271,-4.68532 c -11.10189,11.88809 -23.124233,13.48775 -34.745034,10.69078 -11.620801,-2.79697 -16.420919,-10.7759 -20.062499,-18.2612 -3.64158,-7.4853 -2.976265,-15.74301 -1.181174,-23.10379 0.577547,5.393 -0.671158,8.37123 3.260045,17.24516 3.224283,5.84857 7.36483,10.47545 13.229166,12.80395 7.102803,3.17859 16.477397,1.7222 21.308409,-1.55916 l 7.276037,-6.2366 z"
|
||||
style="fill:#00b786;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6" />
|
||||
<path
|
||||
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
|
||||
fill-opacity=".941"
|
||||
fill="#2d4a5f"
|
||||
id="path2"
|
||||
style="fill:#ffffff;fill-opacity:1"
|
||||
inkscape:export-filename="/mnt/apache/sandpit/go/mailpit/server/ui/mailpit.png"
|
||||
inkscape:export-xdpi="12.29"
|
||||
inkscape:export-ydpi="12.29" />
|
||||
<path
|
||||
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
|
||||
fill="#00b786"
|
||||
id="path4"
|
||||
inkscape:export-filename="/mnt/apache/sandpit/go/mailpit/server/ui/mailpit.png"
|
||||
inkscape:export-xdpi="12.29"
|
||||
inkscape:export-ydpi="12.29" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -5,10 +5,11 @@
|
||||
package websockets
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"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)
|
||||
logger.Log().Error(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"))
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ package websockets
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/axllent/mailpit/data"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
)
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages to the
|
||||
@@ -27,6 +26,12 @@ type Hub struct {
|
||||
unregister chan *Client
|
||||
}
|
||||
|
||||
// WebsocketNotification struct for responses
|
||||
type WebsocketNotification struct {
|
||||
Type string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
// NewHub returns a new hub configuration
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
@@ -49,7 +54,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:
|
||||
@@ -68,7 +73,7 @@ func Broadcast(t string, msg interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
w := data.WebsocketNotification{}
|
||||
w := WebsocketNotification{}
|
||||
w.Type = t
|
||||
w.Data = msg
|
||||
b, err := json.Marshal(w)
|
||||
|
||||
@@ -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/axllent/mailpit/utils/logger"
|
||||
"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()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +1,108 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -119,40 +118,122 @@ 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)
|
||||
|
||||
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(DefaultMailbox, id, msg.Inline[0].PartID)
|
||||
|
||||
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()
|
||||
|
||||
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)).
|
||||
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := Store(buf.Bytes()); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
searchIndx := rand.Intn(4) + 1
|
||||
var search string
|
||||
switch searchIndx {
|
||||
case 1:
|
||||
search = fmt.Sprintf("from-%d@example.com", i)
|
||||
case 2:
|
||||
search = fmt.Sprintf("to-%d@example.com", i)
|
||||
case 3:
|
||||
search = fmt.Sprintf("\"Subject line %d end\"", i)
|
||||
default:
|
||||
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
|
||||
}
|
||||
|
||||
summaries, err := Search(search, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "1 search result expected")
|
||||
|
||||
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
|
||||
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 rsults
|
||||
summaries, err := Search("This is the email body", 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -168,7 +249,6 @@ func setup() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
@@ -178,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))
|
||||
}
|
||||
}
|
||||
|
||||
104
storage/search.go
Normal file
104
storage/search.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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, start, limit int) *sqlf.Stmt {
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
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(limit).
|
||||
Offset(start)
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
103
storage/stats.go
103
storage/stats.go
@@ -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()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package data
|
||||
package storage
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
@@ -17,7 +17,6 @@ type Message struct {
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Date time.Time
|
||||
Created time.Time
|
||||
Text string
|
||||
HTML string
|
||||
Size int
|
||||
@@ -34,8 +33,8 @@ type Attachment struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
// Summary struct for frontend messages
|
||||
type Summary struct {
|
||||
// MessageSummary struct for frontend messages
|
||||
type MessageSummary struct {
|
||||
ID string
|
||||
Read bool
|
||||
From *mail.Address
|
||||
@@ -48,6 +47,12 @@ type Summary struct {
|
||||
Attachments int
|
||||
}
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total int
|
||||
Unread int
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
140
storage/utils.go
140
storage/utils.go
@@ -1,17 +1,20 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"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
|
||||
@@ -42,50 +45,121 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
b.WriteString(a.FileName + " ")
|
||||
}
|
||||
|
||||
d := b.String()
|
||||
|
||||
// remove/replace new lines
|
||||
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|:|\,|;)`)
|
||||
d = re.ReplaceAllString(d, " ")
|
||||
// remove duplicate whitespace and trim
|
||||
d = strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(d)), " "))
|
||||
d := cleanString(b.String())
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Auto-prune runs every 5 minutes to automatically delete oldest messages
|
||||
// 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 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, "%", "%%")
|
||||
}
|
||||
|
||||
@@ -19,16 +19,20 @@ func Log() *logrus.Logger {
|
||||
log = logrus.New()
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
if config.VerboseLogging {
|
||||
// verbose logging (debug)
|
||||
log.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
if config.NoLogging {
|
||||
} else if config.QuietLogging {
|
||||
// show errors only
|
||||
log.SetLevel(logrus.ErrorLevel)
|
||||
} else if config.NoLogging {
|
||||
// disable all logging (tests)
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
}
|
||||
|
||||
log.Out = os.Stdout
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "15:04:05",
|
||||
TimestampFormat: "2006/01/02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/semver"
|
||||
)
|
||||
|
||||
@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
// get the running binary
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = replaceFile(oldExec, newExec); err != nil {
|
||||
Reference in New Issue
Block a user