mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 03:57:01 +00:00
Compare commits
416 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ae4188fe | ||
|
|
3e241a8c20 | ||
|
|
b4003f6899 | ||
|
|
44fb691971 | ||
|
|
ee301c79fb | ||
|
|
7318c5ca4a | ||
|
|
10021e7a92 | ||
|
|
41160fe5bb | ||
|
|
0454840da1 | ||
|
|
e812d12590 | ||
|
|
0bff5fa0c2 | ||
|
|
c1dd84fd77 | ||
|
|
6777e7737f | ||
|
|
dda0b0c8a6 | ||
|
|
c256b91de7 | ||
|
|
2ad458002c | ||
|
|
f4f6a9b217 | ||
|
|
193f38d063 | ||
|
|
a31672b6f3 | ||
|
|
5271f5226b | ||
|
|
7f31fb716a | ||
|
|
320a2024a4 | ||
|
|
6e4b7b3a15 | ||
|
|
b21f1d422e | ||
|
|
9816c80c59 | ||
|
|
d212063d22 | ||
|
|
6725db4fa5 | ||
|
|
3f98ac5087 | ||
|
|
76c2350d03 | ||
|
|
d32600e910 | ||
|
|
35a4c5e13f | ||
|
|
0261f87faf | ||
|
|
98a15e5918 | ||
|
|
128796d4ca | ||
|
|
9cda71f21a | ||
|
|
9a63567b0c | ||
|
|
cb667eabee | ||
|
|
fa8b398afc | ||
|
|
b8385dc18b | ||
|
|
0c3519cb0d | ||
|
|
8c86cc624e | ||
|
|
4d2b6d6b4a | ||
|
|
669c1a747f | ||
|
|
119e6a55d2 | ||
|
|
381813fe63 | ||
|
|
dd57596fd1 | ||
|
|
12cfb09774 | ||
|
|
a25c7e359a | ||
|
|
d705571cb5 | ||
|
|
f4c703b686 | ||
|
|
cdab59b295 | ||
|
|
aad15945b3 | ||
|
|
761cd2cd2e | ||
|
|
7658fd8157 | ||
|
|
2086d0f114 | ||
|
|
8774b57a61 | ||
|
|
d8034b66d1 | ||
|
|
4ecb70d60d | ||
|
|
42dcb05b8a | ||
|
|
6aa23d987a | ||
|
|
857df79dd5 | ||
|
|
8f3a5e1fba | ||
|
|
f787df2c8b | ||
|
|
0af11fcb28 | ||
|
|
e0dc3726bc | ||
|
|
bf181eaad5 | ||
|
|
38a260a4eb | ||
|
|
69646d06c5 | ||
|
|
c2d76b1edd | ||
|
|
b3c82976b1 | ||
|
|
c70d101d7b | ||
|
|
06ca217cde | ||
|
|
e032d27ef6 | ||
|
|
5807747fa5 | ||
|
|
c316132102 | ||
|
|
79807586be | ||
|
|
83e291208a | ||
|
|
4568b95bd6 | ||
|
|
0f0717786e | ||
|
|
9bfd93b295 | ||
|
|
c0e939f99b | ||
|
|
c0be3da5bf | ||
|
|
5f22d33e74 | ||
|
|
a522d21bb4 | ||
|
|
262b77b0fe | ||
|
|
a32978d149 | ||
|
|
0808e4543f | ||
|
|
a8d5887f4f | ||
|
|
bc75701abd | ||
|
|
176d026fcc | ||
|
|
fe82df6f30 | ||
|
|
085e76f33e | ||
|
|
f69106a67a | ||
|
|
28cd1fceee | ||
|
|
2b6e5fe320 | ||
|
|
37e36aaeb6 | ||
|
|
b1c45e1eff | ||
|
|
701741a723 | ||
|
|
b7d7be64fb | ||
|
|
a4582cec4b | ||
|
|
a4b7552be2 | ||
|
|
45b148ecc8 | ||
|
|
0a60ec3f3d | ||
|
|
4a12f2cd62 | ||
|
|
64483e5ce3 | ||
|
|
5365313f9a | ||
|
|
3a35ded5bf | ||
|
|
ee39f33f84 | ||
|
|
8e9476e3df | ||
|
|
ceb4c03dc3 | ||
|
|
1c565dc564 | ||
|
|
f2c517f892 | ||
|
|
97f1530c89 | ||
|
|
945da2c75c | ||
|
|
2e9d5008c2 | ||
|
|
cfcb4f0c97 | ||
|
|
b1c9fb6cf6 | ||
|
|
daac2fc921 | ||
|
|
359573c231 | ||
|
|
13c72e4fe5 | ||
|
|
ad91c10744 | ||
|
|
d013158ac3 | ||
|
|
ef41de06ae | ||
|
|
e80c230120 | ||
|
|
79dad9920a | ||
|
|
7244f4e2ff | ||
|
|
ab8466ff7e | ||
|
|
a5bec762d4 | ||
|
|
4c5b024eca | ||
|
|
74236258db | ||
|
|
ffe6167d96 | ||
|
|
baa9f3be0b | ||
|
|
2605bc5bfb | ||
|
|
bc963ccfb2 | ||
|
|
8b03d1703a | ||
|
|
8e4d03c044 | ||
|
|
c6c32f232c | ||
|
|
f19ab57e06 | ||
|
|
153eb3df53 | ||
|
|
f29016a175 | ||
|
|
d82b1e731c | ||
|
|
615db61df3 | ||
|
|
8f5ef400d8 | ||
|
|
2efa206c4f | ||
|
|
3586abab1c | ||
|
|
420fc9f511 | ||
|
|
6a9bf1d99f | ||
|
|
8908706c1c | ||
|
|
2812c63b01 | ||
|
|
0849970442 | ||
|
|
140bdd6c20 | ||
|
|
f256d205ed | ||
|
|
30c392bcec | ||
|
|
80bf374d8a | ||
|
|
e5ef02e57b | ||
|
|
ccd27e2b94 | ||
|
|
c5ea550631 | ||
|
|
b4f5aa3640 | ||
|
|
82d54d354c | ||
|
|
7185649bbd | ||
|
|
506400b764 | ||
|
|
0e01b9ff73 | ||
|
|
4c3e073b0c | ||
|
|
e72dd8d9b6 | ||
|
|
e564637203 | ||
|
|
cded4d25fc | ||
|
|
eeac32d09b | ||
|
|
e9d44c55a1 | ||
|
|
a9fe0d8e58 | ||
|
|
93da18778c | ||
|
|
9b67792669 | ||
|
|
8739428136 | ||
|
|
97ec3e839b | ||
|
|
56d61ae24b | ||
|
|
d43560d45b | ||
|
|
a0e69a202a | ||
|
|
fc95241521 | ||
|
|
831157a52e | ||
|
|
18c3847deb | ||
|
|
21134c5bbc | ||
|
|
b34877b3ff | ||
|
|
47d6e319e3 | ||
|
|
a64e964c39 | ||
|
|
e5703d0805 | ||
|
|
c004c1065e | ||
|
|
af93444374 | ||
|
|
840bc94190 | ||
|
|
4e2d4d6365 | ||
|
|
7446f52205 | ||
|
|
d4218df1cf | ||
|
|
2b18b1bee1 | ||
|
|
a3f83ea5ce | ||
|
|
52405915fa | ||
|
|
636918dd0e | ||
|
|
3fb926f015 | ||
|
|
0af6850d34 | ||
|
|
66660b9074 | ||
|
|
3b43a803af | ||
|
|
ec3dd0c196 | ||
|
|
38240ae96d | ||
|
|
d0087423db | ||
|
|
1ac8e3a79f | ||
|
|
67dedd8acc | ||
|
|
4f6caca352 | ||
|
|
b6fdcd4ec5 | ||
|
|
044525fcca | ||
|
|
0ab4210640 | ||
|
|
e902806ea2 | ||
|
|
f2b6ba0d69 | ||
|
|
55bdd45247 | ||
|
|
0b3a5fc5d8 | ||
|
|
3e90391991 | ||
|
|
ae15cac727 | ||
|
|
1020f76bf8 | ||
|
|
42a1fe1510 | ||
|
|
628b7e7881 | ||
|
|
fe5de77253 | ||
|
|
36eef88885 | ||
|
|
737cff5a96 | ||
|
|
009a7deaa1 | ||
|
|
b6d5a8c182 | ||
|
|
10224e7c8b | ||
|
|
d2086922e5 | ||
|
|
3c744edd20 | ||
|
|
7ed522e596 | ||
|
|
26c6f9d965 | ||
|
|
76a261bf06 | ||
|
|
86a3bea300 | ||
|
|
5fa6b20a53 | ||
|
|
3ad62769a6 | ||
|
|
a63952aee6 | ||
|
|
de95910539 | ||
|
|
60a41ce3ca | ||
|
|
898b36ce0b | ||
|
|
b4a4d44492 | ||
|
|
64e4e4240a | ||
|
|
0477c6573f | ||
|
|
28ac6d2099 | ||
|
|
43a1dbe3f0 | ||
|
|
aa3f860540 | ||
|
|
f54a2187ac | ||
|
|
063eab2c6a | ||
|
|
b282e6663b | ||
|
|
df777c6e90 | ||
|
|
8c4b1ac445 | ||
|
|
309c56566c | ||
|
|
12d47a0f82 | ||
|
|
27d601294a | ||
|
|
98343714be | ||
|
|
930901c4ec | ||
|
|
446cae145f | ||
|
|
6a4e5fb03c | ||
|
|
8f0549c596 | ||
|
|
4a762c502e | ||
|
|
9af04f83a3 | ||
|
|
8e0c174bf3 | ||
|
|
b193851269 | ||
|
|
95e346f8af | ||
|
|
582f1f88b2 | ||
|
|
0d084cfa1d | ||
|
|
aa0af5de32 | ||
|
|
ee49149df9 | ||
|
|
e18c45d0b3 | ||
|
|
87a68f6a53 | ||
|
|
6d35b7bc82 | ||
|
|
6cf7cba6b7 | ||
|
|
9788a01617 | ||
|
|
f4923c34ae | ||
|
|
b2ce855774 | ||
|
|
d489675c42 | ||
|
|
2ebaaa0fb2 | ||
|
|
80eba20679 | ||
|
|
1757a0086e | ||
|
|
e265d7018e | ||
|
|
a37da776d7 | ||
|
|
5baa598453 | ||
|
|
9d4bbe82e3 | ||
|
|
69226e91b2 | ||
|
|
8646efc979 | ||
|
|
7c42540427 | ||
|
|
c695cd23f6 | ||
|
|
bc53a34029 | ||
|
|
270d5f534f | ||
|
|
6a34c449a2 | ||
|
|
1723497c5c | ||
|
|
57e856f941 | ||
|
|
72d780fe66 | ||
|
|
4768b7b08c | ||
|
|
d01fb4044e | ||
|
|
8dbc661cb7 | ||
|
|
bc4b028c39 | ||
|
|
7875160aa7 | ||
|
|
f0c77ac962 | ||
|
|
5dbc585fce | ||
|
|
63fd86499d | ||
|
|
6db28c5ef7 | ||
|
|
92390a0999 | ||
|
|
149bfa80c2 | ||
|
|
6d2fab1bc6 | ||
|
|
93a6107df2 | ||
|
|
8c3705cc5d | ||
|
|
6379af5604 | ||
|
|
103bd564ab | ||
|
|
86a4633d24 | ||
|
|
80fa989a32 | ||
|
|
66850633a1 | ||
|
|
6c7a1d1ea2 | ||
|
|
0998595690 | ||
|
|
677b00e29a | ||
|
|
ba8b4366ce | ||
|
|
24fb49d079 | ||
|
|
c8a2effac4 | ||
|
|
9f63010ca5 | ||
|
|
f8d514e9e3 | ||
|
|
1922651d41 | ||
|
|
7d2716ee17 | ||
|
|
4c1df6f61e | ||
|
|
be3979241f | ||
|
|
aeb3585f3e | ||
|
|
b8de57da27 | ||
|
|
56982798dc | ||
|
|
ac0e7163dd | ||
|
|
7638500c05 | ||
|
|
5d63e9be9e | ||
|
|
672d9b7c26 | ||
|
|
d9be8f86d7 | ||
|
|
e3e827b180 | ||
|
|
daf6e453df | ||
|
|
9cb2c26c6f | ||
|
|
0aa8ea3d51 | ||
|
|
e05b284c2c | ||
|
|
d39b65deb7 | ||
|
|
7b8faa8a28 | ||
|
|
ebb98c99c0 | ||
|
|
a726cf9922 | ||
|
|
5d146a23d7 | ||
|
|
a6c1bbc977 | ||
|
|
d020861559 | ||
|
|
7fd3291040 | ||
|
|
479c74500c | ||
|
|
6b6de59c47 | ||
|
|
a5de4e4f65 | ||
|
|
48f22cca1f | ||
|
|
7748846b88 | ||
|
|
497086cb65 | ||
|
|
42ecadab9e | ||
|
|
4cfde7f947 | ||
|
|
70b604e028 | ||
|
|
8c295d4754 | ||
|
|
a1c34b37e1 | ||
|
|
e37583073e | ||
|
|
4de830c490 | ||
|
|
22a4509b13 | ||
|
|
1ed06161a8 | ||
|
|
a7ee479f06 | ||
|
|
93e8884ef7 | ||
|
|
1c228cda56 | ||
|
|
119b3864b2 | ||
|
|
b9f035790d | ||
|
|
1260c2e6df | ||
|
|
3431f18a3f | ||
|
|
2fa5138b49 | ||
|
|
652fec0f64 | ||
|
|
f168e11b05 | ||
|
|
35e81e0336 | ||
|
|
7beed988e5 | ||
|
|
4eea79f0c8 | ||
|
|
39767e979c | ||
|
|
4e2f02ee0a | ||
|
|
5a04534314 | ||
|
|
6725a809d5 | ||
|
|
64a067cff9 | ||
|
|
58dbccc0a7 | ||
|
|
3ef320d277 | ||
|
|
18e95b699e | ||
|
|
fc89655b7f | ||
|
|
ff9a6ff491 | ||
|
|
adce75ab8f | ||
|
|
12903cae60 | ||
|
|
7f55511c82 | ||
|
|
309036fb6d | ||
|
|
48387c3a13 | ||
|
|
a2ab350aff | ||
|
|
c150f1ba50 | ||
|
|
48bec0c8f6 | ||
|
|
fef2628c3f | ||
|
|
e5888ede8b | ||
|
|
374a760b88 | ||
|
|
0fdfa13a38 | ||
|
|
b41df78c4f | ||
|
|
870e523c97 | ||
|
|
0b391b5c37 | ||
|
|
c01f473e79 | ||
|
|
3c27fd715b | ||
|
|
714596a13a | ||
|
|
9ae02daf1a | ||
|
|
b6750600cb | ||
|
|
78e871e9b3 | ||
|
|
8ff2a5cf6a | ||
|
|
4a88d1fc24 | ||
|
|
d4268b8ae1 | ||
|
|
1b47716f5f | ||
|
|
42e6d71415 | ||
|
|
cd5789dda2 | ||
|
|
cd2a9d433a | ||
|
|
fe0dfe41e7 | ||
|
|
bee3174c78 | ||
|
|
a3187d5499 | ||
|
|
dc7f047b9a | ||
|
|
f3bb522143 | ||
|
|
3a41d56cc6 | ||
|
|
db5d8f672a | ||
|
|
3d96b2cad0 | ||
|
|
34c1748f4b | ||
|
|
52120abefd | ||
|
|
086142e977 |
10
.github/workflows/build-docker.yml
vendored
10
.github/workflows/build-docker.yml
vendored
@@ -8,16 +8,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
version_extractor_regex: 'v(.*)$'
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
|
||||
|
||||
6
.github/workflows/close-stale-issues.yml
vendored
6
.github/workflows/close-stale-issues.yml
vendored
@@ -10,13 +10,13 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v8.0.0
|
||||
- uses: actions/stale@v9.0.0
|
||||
with:
|
||||
days-before-issue-stale: 21
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
exempt-issue-labels: "enhancement,bug,javascript,docker"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 21 days with no activity."
|
||||
stale-issue-message: "This issue is stale because it has been open for 14 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -38,11 +38,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
# 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
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ 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
|
||||
@@ -69,4 +69,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
9
.github/workflows/release-build.yml
vendored
9
.github/workflows/release-build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
name: Build & release
|
||||
jobs:
|
||||
releases-matrix:
|
||||
name: Release Go Binary
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- run: npm run package
|
||||
|
||||
# build the binaries
|
||||
- uses: wangyoucao577/go-release-action@v1.38
|
||||
- uses: wangyoucao577/go-release-action@v1.46
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
@@ -44,4 +44,5 @@ jobs:
|
||||
extra_files: LICENSE README.md
|
||||
md5sum: false
|
||||
overwrite: true
|
||||
retry: 5
|
||||
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"
|
||||
|
||||
35
.github/workflows/tests.yml
vendored
35
.github/workflows/tests.yml
vendored
@@ -1,22 +1,24 @@
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.18.x]
|
||||
os: [ubuntu-latest]
|
||||
go-version: [1.21.x]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/cache@v3
|
||||
cache: false
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Go tests
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -24,13 +26,24 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test ./storage ./server -v
|
||||
- run: go test ./storage -bench=.
|
||||
- run: go test ./internal/storage ./server ./internal/tools ./internal/html2text -v
|
||||
- run: go test ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Build web UI
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm run package
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm install
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run package
|
||||
|
||||
# validate the swagger file
|
||||
- name: Validate OpenAPI definition
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: char0n/swagger-editor-validate@v1
|
||||
with:
|
||||
definition-file: server/ui/api/v1/swagger.json
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
/node_modules/
|
||||
/send
|
||||
/sendmail/sendmail
|
||||
/server/ui/dist
|
||||
/Makefile
|
||||
/mailpit*
|
||||
/.idea
|
||||
*.old
|
||||
*.db
|
||||
|
||||
550
CHANGELOG.md
550
CHANGELOG.md
@@ -2,6 +2,556 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.13.2]
|
||||
|
||||
### Chore
|
||||
- Update caniemail data
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
- Bump actions build requirement versions
|
||||
- Update esbuild
|
||||
|
||||
### Feature
|
||||
- Add option to log output to file ([#246](https://github.com/axllent/mailpit/issues/246))
|
||||
|
||||
|
||||
## [v1.13.1]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Feature
|
||||
- Add TLSRequired option for smtpd ([#241](https://github.com/axllent/mailpit/issues/241))
|
||||
|
||||
### Fix
|
||||
- Workaround for specific field searches containing unicode characters ([#239](https://github.com/axllent/mailpit/issues/239))
|
||||
|
||||
### UI
|
||||
- Only show number of messages ignored statistics if `--ignore-duplicate-ids` is set
|
||||
|
||||
|
||||
## [v1.13.0]
|
||||
|
||||
### Chore
|
||||
- Compress compiled assets with `npm run build`
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### Feature
|
||||
- Add option to disable SMTP reverse DNS (rDNS) lookup ([#230](https://github.com/axllent/mailpit/issues/230))
|
||||
- Display List-Unsubscribe & List-Unsubscribe-Post header info with syntax validation ([#236](https://github.com/axllent/mailpit/issues/236))
|
||||
- Add optional SpamAssassin integration to display scores ([#233](https://github.com/axllent/mailpit/issues/233))
|
||||
|
||||
### Fix
|
||||
- Display multiple whitespace characters in message subject & recipient names ([#238](https://github.com/axllent/mailpit/issues/238))
|
||||
- Sendmail support for `-f 'Name <email[@example](https://github.com/example).com>'` format
|
||||
|
||||
|
||||
## [v1.12.1]
|
||||
|
||||
### Chore
|
||||
- Significantly increase database performance using WAL (Write-Ahead-Log)
|
||||
- Standardize error logging & formatting
|
||||
|
||||
### Feature
|
||||
- Add option to only allow SMTP recipients matching a regular expression (disable open-relay behaviour [#219](https://github.com/axllent/mailpit/issues/219))
|
||||
|
||||
### Fix
|
||||
- Log total deleted messages when auto-pruning messages (--max)
|
||||
- Prevent rare error from websocket connection (unexpected non-whitespace character)
|
||||
- Log total deleted messages when deleting all messages from search
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
### Tests
|
||||
- Run tests on Linux, Windows & Mac
|
||||
|
||||
### UI
|
||||
- Automatically refresh connected browsers if Mailpit is upgraded (version change)
|
||||
|
||||
|
||||
## [v1.12.0]
|
||||
|
||||
### Chore
|
||||
- Include runtime statistics in API (info) & UI (About)
|
||||
- Use memory pointer for internal message parsing & storage
|
||||
- Update caniemail test data
|
||||
- Convert to many-to-many message tag relationships
|
||||
- Standardize error logging & formatting
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Refresh search results when search resubmitted or active tag filter clicked
|
||||
|
||||
|
||||
## [v1.11.1]
|
||||
|
||||
### Fix
|
||||
- Fix regression to support for search query params to all `/latest` endpoints ([#206](https://github.com/axllent/mailpit/issues/206))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Testing
|
||||
- Add new `ingest` subcommand to import an email file or maildir folder over SMTP
|
||||
|
||||
### UI
|
||||
- Allow multiple tags to be searched using Ctrl-click ([#216](https://github.com/axllent/mailpit/issues/216))
|
||||
|
||||
|
||||
## [v1.11.0]
|
||||
|
||||
### API
|
||||
- Allow ID "latest" for message summary, headers, raw version & HTML/link checks
|
||||
|
||||
### Feature
|
||||
- Add configuration option to set maximum SMTP recipients ([#205](https://github.com/axllent/mailpit/issues/205))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.10.4]
|
||||
|
||||
### Fix
|
||||
- Remove JS debug information for favicon
|
||||
|
||||
|
||||
## [v1.10.3]
|
||||
|
||||
### Chore
|
||||
- Update caniemail library & add `hr` element test
|
||||
|
||||
### Feature
|
||||
- Add @ as valid character for webroot ([#215](https://github.com/axllent/mailpit/issues/215))
|
||||
|
||||
### Fix
|
||||
- New favicon notification badge to fix rendering issues ([#210](https://github.com/axllent/mailpit/issues/210))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.10.2]
|
||||
|
||||
### Chore
|
||||
- Add favicon fallback font (sans-serif) for unread count
|
||||
- Clearer log messages for bound SMTP & HTTP addresses
|
||||
|
||||
### Feature
|
||||
- Allow port binding using hostname
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Enable tag colors by default
|
||||
|
||||
|
||||
## [v1.10.1]
|
||||
|
||||
### Chore
|
||||
- Use NextReader() instead of ReadMessage() for websocket reading ([#207](https://github.com/axllent/mailpit/issues/207))
|
||||
|
||||
### Fix
|
||||
- Prevent JavaScript error if message is missing `From` header ([#209](https://github.com/axllent/mailpit/issues/209))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Revert BinaryResponse type to string
|
||||
|
||||
|
||||
## [v1.10.0]
|
||||
|
||||
### Feature
|
||||
- Support search query params to /latest endpoints ([#206](https://github.com/axllent/mailpit/issues/206))
|
||||
- Option to allow untrusted HTTPS certificates for screenshots & link checking ([#204](https://github.com/axllent/mailpit/issues/204))
|
||||
- Add URL redirect (`/view/latest`) to view latest message in web UI ([#166](https://github.com/axllent/mailpit/issues/166))
|
||||
|
||||
### Fix
|
||||
- Correctly close websockets on client disconnect ([#207](https://github.com/axllent/mailpit/issues/207))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.9.10]
|
||||
|
||||
### Docs
|
||||
- Update documentation links
|
||||
|
||||
### Fix
|
||||
- Correctly display "About" modal when update check fails (resolves [#199](https://github.com/axllent/mailpit/issues/199))
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
- Update caniemail test data
|
||||
|
||||
### UI
|
||||
- Fix column width in search view
|
||||
|
||||
|
||||
## [v1.9.9]
|
||||
|
||||
### Chore
|
||||
- Move html2text module to internal/html2text
|
||||
|
||||
### Feature
|
||||
- Set optional webhook for received messages ([#195](https://github.com/axllent/mailpit/issues/195))
|
||||
- Reset message date on release ([#194](https://github.com/axllent/mailpit/issues/194))
|
||||
|
||||
### Libs
|
||||
- update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.9.8]
|
||||
|
||||
### Chore
|
||||
- Replace satori/go.uuid with github.com/google/uuid ([#190](https://github.com/axllent/mailpit/issues/190))
|
||||
- Replace html2text modules with simplified internal function
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger documentation
|
||||
|
||||
### Tests
|
||||
- Add test to validate swagger.json
|
||||
- Add html2text tests
|
||||
|
||||
|
||||
## [v1.9.7]
|
||||
|
||||
### Fix
|
||||
- Enable delete button when new messages arrive
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Downgrade microcosm-cc/bluemonday, revert to Go 1.20
|
||||
- Update Go modules & minimum Go version (1.21)
|
||||
|
||||
|
||||
## [v1.9.6]
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Display message previews on separate line ([#175](https://github.com/axllent/mailpit/issues/175))
|
||||
|
||||
|
||||
## [v1.9.5]
|
||||
|
||||
### Feature
|
||||
- Add `reindex` subcommand to reindex all messages
|
||||
- Display email previews ([#175](https://github.com/axllent/mailpit/issues/175))
|
||||
|
||||
### Fix
|
||||
- HTML message preview background color when switching themes in Chrome
|
||||
- Correctly detect tags in search (UI)
|
||||
|
||||
### Tests
|
||||
- Add message summary tests
|
||||
- Add snippet tests
|
||||
|
||||
|
||||
## [v1.9.4]
|
||||
|
||||
### Chore
|
||||
- Remove some flags deprecated 08/2022
|
||||
|
||||
### Feature
|
||||
- Set auth credentials directly from environment variables
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Add option to delete a message after release
|
||||
|
||||
|
||||
## [v1.9.3]
|
||||
|
||||
### Chore
|
||||
- Update internal/storage import paths
|
||||
- Move storage package to internal/storage
|
||||
- Update internal import paths
|
||||
- Move utils/* packages to internal/*
|
||||
|
||||
### Testing
|
||||
- Add endpoints for integration tests
|
||||
|
||||
### Tests
|
||||
- Add more API tests
|
||||
- Add tests for ArgsParser & CleanTag
|
||||
|
||||
### UI
|
||||
- Do not show excluded search tags as "current" in nav
|
||||
- Display "Loading messages" instead of "No results" while loading results
|
||||
- Only queue broadcast events if clients are connected
|
||||
|
||||
|
||||
## [v1.9.2]
|
||||
|
||||
### Fix
|
||||
- Delete all messages matching search when more than 1000 results
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
### Tests
|
||||
- Add message tag tests
|
||||
- Add search delete tests
|
||||
|
||||
### UI
|
||||
- Reset pagination when returning to inbox from search
|
||||
|
||||
|
||||
## [v1.9.1]
|
||||
|
||||
### Chore
|
||||
- Update caniemail data
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Set 404 page when loading a non-existent message
|
||||
- Link email addresses in message summary to search
|
||||
- Better support for mobile screen sizes
|
||||
|
||||
|
||||
## [v1.9.0]
|
||||
|
||||
### API
|
||||
- Remove redundant `Read` status from message (always true)
|
||||
- Delete by search filter
|
||||
- Add endpoint to return all tags in use
|
||||
|
||||
### Feature
|
||||
- Improved search parser
|
||||
- New search filter `[!]is:tagged`
|
||||
|
||||
### Fix
|
||||
- Correctly escape certain characters in search (eg: `'`)
|
||||
|
||||
### Libs
|
||||
- Update minimum Go version to 1.20
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### Tests
|
||||
- Bump Go version to 1.21
|
||||
|
||||
### UI
|
||||
- Rewrite web UI, add URL routing and components
|
||||
|
||||
|
||||
## [v1.8.4]
|
||||
|
||||
### Fix
|
||||
- Correctly decode proxy links containing HTML entities (screenshots)
|
||||
|
||||
|
||||
## [v1.8.3]
|
||||
|
||||
### Feature
|
||||
- HTML screenshots
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Group message tabs on mobile
|
||||
|
||||
|
||||
## [v1.8.2]
|
||||
|
||||
### Build
|
||||
- Update wangyoucao577/go-release-action[@v1](https://github.com/v1).39
|
||||
|
||||
### Feature
|
||||
- Link check to test message links
|
||||
- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
|
||||
|
||||
### Libs
|
||||
- Update Go libs
|
||||
|
||||
### UI
|
||||
- Set hostname in page meta title to identify Mailpit instance
|
||||
|
||||
|
||||
## [v1.8.1]
|
||||
|
||||
### Docs
|
||||
- Add pagination to swagger search documentation
|
||||
|
||||
### Fix
|
||||
- Check/set message Reply-To using SMTP FROM
|
||||
- Exclude "sendmail" from recipients list when using `mailpit sendmail <options>`
|
||||
- Exclude <script type="application/json"> from HTML check tests
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
|
||||
## [v1.8.0]
|
||||
|
||||
### Docs
|
||||
- Update brew installation instructions
|
||||
|
||||
### Feature
|
||||
- HTML check to test & score mail client compatibility with HTML emails
|
||||
|
||||
### Fix
|
||||
- Add basePath to swagger.json if webroot is specified
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### Swagger
|
||||
- Update swagger docs
|
||||
|
||||
### UI
|
||||
- Add flag to block all access to remote CSS and fonts (CSP)
|
||||
- Remove `<base />` tag if set in HTML preview
|
||||
- Pagination support for search, all results
|
||||
|
||||
|
||||
## [v1.7.1]
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
|
||||
### UI
|
||||
- Wrap HTML source lines
|
||||
- Dark mode color adjustments
|
||||
- Update dark mode loading background color
|
||||
|
||||
|
||||
## [v1.7.0]
|
||||
|
||||
### API
|
||||
- Ignore SMTP relay error when one of multiple recipients doesn't exist
|
||||
- Set raw message Content-Type to UTF-8
|
||||
|
||||
### Build
|
||||
- Define Vue build options in esbuild
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
- Update Go modules
|
||||
|
||||
### UI
|
||||
- Theme toggler - auto, light and dark themes
|
||||
|
||||
|
||||
## [v1.6.22]
|
||||
|
||||
### Feature
|
||||
- Clearer SMTP error messages
|
||||
|
||||
### Libs
|
||||
- Update Go modules
|
||||
- Upgrade node modules
|
||||
|
||||
|
||||
## [v1.6.21]
|
||||
|
||||
### UI
|
||||
- More accurate clickable hyperlink logic in plain text messages
|
||||
|
||||
|
||||
## [v1.6.20]
|
||||
|
||||
### Feature
|
||||
- Convert links into clickable hyperlinks in plain text message content
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.19]
|
||||
|
||||
### Fix
|
||||
- Only display sendmail help when sendmail subcommand is invoked
|
||||
|
||||
|
||||
## [v1.6.18]
|
||||
|
||||
### API
|
||||
- Sort tags before saving
|
||||
|
||||
### UI
|
||||
- Add option to enable tag colors based on tag name hash
|
||||
- Display message tags below subject in message overview
|
||||
|
||||
|
||||
## [v1.6.17]
|
||||
|
||||
### Fix
|
||||
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
|
||||
|
||||
|
||||
## [v1.6.16]
|
||||
|
||||
### Bugfix
|
||||
- Fix sendmail/startup panic
|
||||
|
||||
|
||||
## [v1.6.15]
|
||||
|
||||
### Feature
|
||||
- Add `sendmail -bs` functionality
|
||||
|
||||
|
||||
## [v1.6.14]
|
||||
|
||||
### Feature
|
||||
- Add ability to delete or mark search results read
|
||||
- Set tags via X-Tags message header
|
||||
|
||||
### Libs
|
||||
- Update node modules
|
||||
|
||||
|
||||
## [v1.6.13]
|
||||
|
||||
### Feature
|
||||
- Add SMTP LOGIN authentication method for message relay
|
||||
|
||||
|
||||
## [v1.6.12]
|
||||
|
||||
### Feature
|
||||
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
|
||||
|
||||
### Swagger
|
||||
- Update swagger field descriptions, add MessageID
|
||||
|
||||
|
||||
## [v1.6.11]
|
||||
|
||||
### Libs
|
||||
|
||||
108
README.md
108
README.md
@@ -1,51 +1,75 @@
|
||||
# Mailpit - email testing for developers
|
||||
<h1 align="center">
|
||||
Mailpit - email testing for developers
|
||||
</h1>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/axllent/mailpit)
|
||||
<div align="center">
|
||||
<a href="https://github.com/axllent/mailpit/actions/workflows/tests.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg" alt="CI Tests status"></a>
|
||||
<a href="https://github.com/axllent/mailpit/actions/workflows/release-build.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg" alt="CI build status"></a>
|
||||
<a href="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg" alt="CI Docker build status"></a>
|
||||
<a href="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg" alt="Code quality"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/axllent/mailpit"><img src="https://goreportcard.com/badge/github.com/axllent/mailpit" alt="Go Report Card"></a>
|
||||
<br>
|
||||
<a href="https://github.com/axllent/mailpit/releases/latest"><img src="https://img.shields.io/github/v/release/axllent/mailpit.svg" alt="Latest release"></a>
|
||||
<a href="https://hub.docker.com/r/axllent/mailpit"><img src="https://img.shields.io/docker/pulls/axllent/mailpit.svg" alt="Docker pulls"></a>
|
||||
</div>
|
||||
<br>
|
||||
<p align="center">
|
||||
<a href="https://mailpit.axllent.org">Website</a> •
|
||||
<a href="https://mailpit.axllent.org/docs/">Documentation</a> •
|
||||
<a href="https://mailpit.axllent.org/docs/api-v1/">API</a>
|
||||
</p>
|
||||
|
||||
Mailpit is a multi-platform email testing tool & API for developers.
|
||||
<hr>
|
||||
|
||||
It acts as both an SMTP server, and provides a web interface to view all captured emails.
|
||||
**Mailpit** is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
|
||||
|
||||
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and includes an API for automated integration testing.
|
||||
|
||||

|
||||
Mailpit was originally **inspired** by MailHog which is [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development or security updates for a few years now.
|
||||
|
||||

|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Runs entirely from a single binary, no installation required
|
||||
- SMTP server (default `0.0.0.0:1025`)
|
||||
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
|
||||
- Runs entirely from a single [static binary](https://mailpit.axllent.org/docs/install/)
|
||||
- Modern web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source, and MIME attachments
|
||||
including image thumbnails), including optional [HTTPS](https://mailpit.axllent.org/docs/configuration/https/)
|
||||
- Optional [basic authentication](https://mailpit.axllent.org/docs/configuration/frontend-authentication/) for web UI & API
|
||||
- [HTML check](https://mailpit.axllent.org/docs/usage/html-check/) to test & score mail client compatibility with HTML emails
|
||||
- [Link check](https://mailpit.axllent.org/docs/usage/link-check/) to test message links (HTML & text) & linked images
|
||||
- [Spam check](https://mailpit.axllent.org/docs/usage/spamassassin/) to test message "spamminess" using a running SpamAssassin server
|
||||
- [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
|
||||
- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging))
|
||||
- Real-time web UI updates using web sockets for new mail
|
||||
- Optional browser notifications for new mail (HTTPS and `localhost` only)
|
||||
- Advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/)
|
||||
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/)
|
||||
- Real-time web UI updates using web sockets for new mail & optional browser notifications for new mail (when accessed
|
||||
via either HTTPS or `localhost` only)
|
||||
- SMTP server with optional [STARTTLS & SMTP authentication](https://mailpit.axllent.org/docs/configuration/smtp-authentication/) (including an
|
||||
"accept any" mode)
|
||||
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server
|
||||
including an optional allowlist of accepted recipients
|
||||
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails
|
||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||
- 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, easily handling tens of thousands of emails
|
||||
- SMTP relaying / message release - relay messages via a different SMTP server including an optional allowlist of accepted recipients ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
|
||||
- Optional SMTP with STARTTLS & SMTP authentication, including an "accept anything" mode ([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 ([see docs](docs/apiv1/README.md))
|
||||
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
- A simple [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
- Multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
|
||||
The Mailpit web UI listens by default on `http://0.0.0.0:8025` and the SMTP port on `0.0.0.0:1025`.
|
||||
|
||||
Mailpit runs as a single binary and can be installed in different ways:
|
||||
|
||||
|
||||
### Install via Brew (Mac)
|
||||
### Install via package managers
|
||||
|
||||
Add the repository to your taps with `brew tap axllent/apps`, and then install Mailpit with `brew install mailpit`.
|
||||
- **Mac**: `brew install mailpit` (to run automatically in the background: `brew services start mailpit`)
|
||||
- **Arch Linux**: available in the AUR as `mailpit`
|
||||
- **FreeBSD**: `pkg install mailpit`
|
||||
|
||||
|
||||
### Install via bash script (Linux & Mac)
|
||||
@@ -59,33 +83,33 @@ sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop
|
||||
|
||||
### Download static binary (Windows, Linux and Mac)
|
||||
|
||||
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can extracted and copied to your `$PATH`, or simply run as `./mailpit`.
|
||||
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can be extracted and copied to your `$PATH`, or simply run as `./mailpit`.
|
||||
|
||||
|
||||
### Docker
|
||||
|
||||
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images).
|
||||
See [Docker instructions](https://mailpit.axllent.org/docs/install/docker/) for 386, amd64 & arm64 images.
|
||||
|
||||
|
||||
### Compile from source
|
||||
|
||||
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
|
||||
To build Mailpit from source, see [Building from source](https://mailpit.axllent.org/docs/install/source/).
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Run `mailpit -h` to see options. More information can be seen in [the docs](https://mailpit.axllent.org/docs/configuration/runtime-options/).
|
||||
|
||||
If installed using homebrew, you may run `brew services start mailpit` to always run mailpit automatically.
|
||||
|
||||
|
||||
### Testing Mailpit
|
||||
|
||||
Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Testing-Mailpit) of how to easily test email delivery to Mailpit.
|
||||
Please refer to [the documentation](https://mailpit.axllent.org/docs/install/testing/) on how to easily test email delivery to Mailpit.
|
||||
|
||||
|
||||
### Configuring sendmail
|
||||
|
||||
Mailpit's SMTP server (by default on port 1025), so you will likely need to configure your sending application to deliver mail via that port. A common MTA (Mail Transfer Agent) that delivers system emails to a SMTP server is `sendmail`, used by many applications including PHP. Mailpit can also act as substitute for sendmail. For instructions of how to set this up, please refer to the [sendmail documentation](https://github.com/axllent/mailpit/wiki/Configuring-sendmail).
|
||||
|
||||
|
||||
## 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 performance issues, many of the frontend and Go modules are horribly out of date, and it is not actively developed.
|
||||
|
||||
Initially I tried 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 to its authors) poorly designed. It is in my opinion over-engineered (split over 9 separate projects), and performs very poorly when dealing with large amounts of emails or processing emails with an attachments (a single email with a 3MB attachment can take over a minute to ingest). Finally, the API transmits a lot of duplicate and unnecessary data on every browser request, and there is no HTTP compression.
|
||||
|
||||
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.
|
||||
Mailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port.
|
||||
A common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP.
|
||||
Mailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).
|
||||
|
||||
153
cmd/ingest.go
Normal file
153
cmd/ingest.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
ingestRecent int
|
||||
)
|
||||
|
||||
// ingestCmd represents the ingest command
|
||||
var ingestCmd = &cobra.Command{
|
||||
Use: "ingest <file|folder> ...[file|folder]",
|
||||
Short: "Ingest a file or folder of emails for testing",
|
||||
Long: `Ingest a file or folder of emails for testing.
|
||||
|
||||
This command will scan the folder for emails and deliver them via SMTP to a running
|
||||
Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox).
|
||||
The --recent flag will only consider files with a modification date within the last X days.`,
|
||||
// Hidden: true,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
for _, a := range args {
|
||||
err := filepath.Walk(a,
|
||||
func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
return nil
|
||||
}
|
||||
if !isFile(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
info.ModTime()
|
||||
|
||||
if ingestRecent > 0 && time.Now().Sub(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
return nil
|
||||
}
|
||||
defer f.Close() // #nosec
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error parsing message body: %s", err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
recipients := []string{}
|
||||
// get all recipients in To, Cc and Bcc
|
||||
if to, err := msg.Header.AddressList("To"); err == nil {
|
||||
for _, a := range to {
|
||||
recipients = append(recipients, a.Address)
|
||||
}
|
||||
}
|
||||
if cc, err := msg.Header.AddressList("Cc"); err == nil {
|
||||
for _, a := range cc {
|
||||
recipients = append(recipients, a.Address)
|
||||
}
|
||||
}
|
||||
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
|
||||
for _, a := range bcc {
|
||||
recipients = append(recipients, a.Address)
|
||||
}
|
||||
}
|
||||
|
||||
if sendmail.FromAddr == "" {
|
||||
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
|
||||
sendmail.FromAddr = fromAddresses[0].Address
|
||||
}
|
||||
}
|
||||
|
||||
if len(recipients) == 0 {
|
||||
// Bcc
|
||||
recipients = []string{sendmail.FromAddr}
|
||||
}
|
||||
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
if returnPath == "" {
|
||||
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
|
||||
returnPath = fromAddresses[0].Address
|
||||
}
|
||||
}
|
||||
|
||||
err = smtp.SendMail(sendmail.SMTPAddr, nil, returnPath, recipients, body)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
|
||||
return nil
|
||||
}
|
||||
|
||||
count++
|
||||
total++
|
||||
if count%100 == 0 {
|
||||
formatted := p.Sprintf("%d", total)
|
||||
logger.Log().Infof("[%s] 100 messages in %s", formatted, time.Since(per100start))
|
||||
|
||||
per100start = time.Now()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(ingestCmd)
|
||||
|
||||
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
36
cmd/reindex.go
Normal file
36
cmd/reindex.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// reindexCmd represents the reindex command
|
||||
var reindexCmd = &cobra.Command{
|
||||
Use: "reindex <database>",
|
||||
Short: "Reindex the database",
|
||||
Long: `This will reindex all messages in the entire database.
|
||||
|
||||
If you have several thousand messages in your mailbox, then it is advised to shut down
|
||||
Mailpit while you reindex as this process will likely result in database locking issues.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config.DataFile = args[0]
|
||||
config.MaxMessages = 0
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
logger.Log().Error(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
storage.ReindexAll()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(reindexCmd)
|
||||
}
|
||||
150
cmd/root.go
150
cmd/root.go
@@ -2,16 +2,17 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ It acts as an SMTP server, and provides a web interface to view all captured ema
|
||||
|
||||
Documentation:
|
||||
https://github.com/axllent/mailpit
|
||||
https://github.com/axllent/mailpit/wiki`,
|
||||
https://mailpit.axllent.org/docs/`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
if err := config.VerifyConfig(); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
@@ -88,8 +89,11 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
|
||||
|
||||
@@ -97,31 +101,25 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPTLSRequired, "smtp-tls-required", config.SMTPTLSRequired, "Require TLS SMTP encryption")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Allow insecure PLAIN & LOGIN SMTP authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
|
||||
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
|
||||
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
|
||||
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
|
||||
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
|
||||
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
|
||||
|
||||
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
|
||||
// deprecated flags 2022/08/06
|
||||
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ssl-cert", config.UITLSCert, "SSL certificate - requires ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ssl-key", config.UITLSKey, "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-tls-cert"
|
||||
rootCmd.Flags().Lookup("ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-tls-key"
|
||||
|
||||
// deprecated flags 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"
|
||||
|
||||
// deprecated flags 2023/03/12
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
|
||||
@@ -139,10 +137,8 @@ func init() {
|
||||
|
||||
// Load settings from environment
|
||||
func initConfigFromEnv() {
|
||||
// defaults from envars if provided
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
config.DataFile = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
// inherit from environment if provided
|
||||
config.DataFile = os.Getenv("MP_DATA_FILE")
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
}
|
||||
@@ -157,25 +153,18 @@ func initConfigFromEnv() {
|
||||
}
|
||||
|
||||
// UI
|
||||
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_TLS_CERT")) > 0 {
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_TLS_KEY")) > 0 {
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
}
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
auth.SetUIAuth(os.Getenv("MP_UI_AUTH"))
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
|
||||
// SMTP
|
||||
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_TLS_CERT")) > 0 {
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_TLS_KEY")) > 0 {
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH"))
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
|
||||
if getEnabledFromEnv("MP_SMTP_TLS_REQUIRED") {
|
||||
config.SMTPTLSRequired = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
@@ -183,15 +172,33 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
|
||||
config.SMTPAuthAllowInsecure = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_STRICT_RFC_HEADERS") {
|
||||
config.SMTPStrictRFCHeaders = true
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_MAX_RECIPIENTS")) > 0 {
|
||||
config.SMTPMaxRecipients, _ = strconv.Atoi(os.Getenv("MP_SMTP_MAX_RECIPIENTS"))
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
|
||||
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
|
||||
smtpd.DisableReverseDNS = true
|
||||
}
|
||||
|
||||
// Relay server config
|
||||
if len(os.Getenv("MP_SMTP_RELAY_CONFIG")) > 0 {
|
||||
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
|
||||
}
|
||||
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
|
||||
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
|
||||
config.SMTPRelayAllIncoming = true
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
config.WebhookURL = os.Getenv("MP_WEBHOOK_URL")
|
||||
}
|
||||
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
|
||||
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
|
||||
}
|
||||
|
||||
// Misc options
|
||||
if len(os.Getenv("MP_WEBROOT")) > 0 {
|
||||
config.Webroot = os.Getenv("MP_WEBROOT")
|
||||
@@ -205,6 +212,21 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
|
||||
config.IgnoreDuplicateIDs = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
|
||||
config.DisableHTMLCheck = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
|
||||
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
|
||||
}
|
||||
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||
config.AllowUntrustedTLS = true
|
||||
}
|
||||
if len(os.Getenv("MP_LOG_FILE")) > 0 {
|
||||
logger.LogFile = os.Getenv("MP_LOG_FILE")
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
}
|
||||
@@ -215,43 +237,31 @@ func initConfigFromEnv() {
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
fmt.Println("ENV MP_AUTH_FILE has been deprecated, use MP_UI_AUTH_FILE")
|
||||
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_CERT")) > 0 {
|
||||
fmt.Println("ENV MP_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
config.UITLSCert = os.Getenv("MP_SSL_CERT")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_KEY")) > 0 {
|
||||
fmt.Println("ENV MP_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
|
||||
config.UITLSKey = os.Getenv("MP_TLS_KEY")
|
||||
}
|
||||
// deprecated 2022/08/28
|
||||
if len(os.Getenv("MP_DATA_DIR")) > 0 {
|
||||
fmt.Println("ENV MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
|
||||
config.DataFile = os.Getenv("MP_DATA_DIR")
|
||||
}
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
}
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
|
||||
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
|
||||
logger.Log().Warn("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
|
||||
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
}
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
|
||||
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
|
||||
logger.Log().Warn("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
|
||||
}
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
|
||||
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
|
||||
logger.Log().Warn("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
|
||||
}
|
||||
// deprecated 2023/12/10
|
||||
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
|
||||
logger.Log().Warn("ENV MP_STRICT_RFC_HEADERS has been deprecated, use MP_SMTP_STRICT_RFC_HEADERS")
|
||||
config.SMTPStrictRFCHeaders = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to get a boolean from an environment variable
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
smtpAddr = "localhost:1025"
|
||||
fromAddr string
|
||||
)
|
||||
|
||||
// sendmailCmd represents the sendmail command
|
||||
var sendmailCmd = &cobra.Command{
|
||||
Use: "sendmail [flags] [recipients]",
|
||||
Short: "A sendmail command replacement for Mailpit",
|
||||
Long: `A sendmail command replacement for Mailpit.
|
||||
|
||||
You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
sendmail.Run()
|
||||
},
|
||||
}
|
||||
@@ -25,13 +20,17 @@ You can optionally create a symlink called 'sendmail' to the Mailpit binary.`,
|
||||
func init() {
|
||||
rootCmd.AddCommand(sendmailCmd)
|
||||
|
||||
// these are simply repeated for cli consistency
|
||||
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&smtpAddr, "smtp-addr", "S", smtpAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
// print out manual help screen
|
||||
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
|
||||
|
||||
// these are simply repeated for cli consistency as cobra/viper does not allow
|
||||
// multi-letter single-dash variables (-bs)
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/updater"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
202
config/config.go
202
config/config.go
@@ -4,15 +4,17 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/mattn/go-shellwords"
|
||||
"github.com/tg123/go-htpasswd"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -38,12 +40,9 @@ var (
|
||||
// UITLSKey file
|
||||
UITLSKey string
|
||||
|
||||
// UIAuthFile for basic authentication
|
||||
// UIAuthFile for UI & API authentication
|
||||
UIAuthFile string
|
||||
|
||||
// UIAuth used for euthentication
|
||||
UIAuth *htpasswd.File
|
||||
|
||||
// Webroot to define the base path for the UI and API
|
||||
Webroot = "/"
|
||||
|
||||
@@ -53,26 +52,39 @@ var (
|
||||
// SMTPTLSKey file
|
||||
SMTPTLSKey string
|
||||
|
||||
// SMTPTLSRequired to enforce TLS
|
||||
// The only allowed commands are NOOP, EHLO, STARTTLS and QUIT (as specified in RFC 3207) until
|
||||
// the connection is upgraded to TLS i.e. until STARTTLS is issued.
|
||||
SMTPTLSRequired bool
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
|
||||
// SMTPAuthConfig used for authentication auto-generated from SMTPAuthFile
|
||||
SMTPAuthConfig *htpasswd.File
|
||||
|
||||
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
|
||||
SMTPAuthAllowInsecure bool
|
||||
|
||||
// SMTPAuthAcceptAny accepts any username/password including none
|
||||
SMTPAuthAcceptAny bool
|
||||
|
||||
// SMTPMaxRecipients is the maximum number of recipients a message may have.
|
||||
// The SMTP RFC states that an server must handle a minimum of 100 recipients
|
||||
// however some servers accept more.
|
||||
SMTPMaxRecipients = 100
|
||||
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// SMTPCLITags is used to map the CLI args
|
||||
SMTPCLITags string
|
||||
|
||||
// TagRegexp is the allowed tag characters
|
||||
TagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
|
||||
// SMTPTags are expressions to apply tags to new mail
|
||||
SMTPTags []AutoTag
|
||||
@@ -83,15 +95,34 @@ var (
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
SMTPRelayConfig smtpRelayConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via preconfgured SMTP server.
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
|
||||
// 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';"
|
||||
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||
EnableSpamAssassin string
|
||||
|
||||
// WebhookURL for calling
|
||||
WebhookURL string
|
||||
|
||||
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
|
||||
ContentSecurityPolicy string
|
||||
|
||||
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
|
||||
AllowUntrustedTLS bool
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
@@ -115,131 +146,168 @@ type smtpRelayConfigStruct struct {
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Auth string `yaml:"auth"` // none, plain, cram-md5
|
||||
Auth string `yaml:"auth"` // none, plain, login, cram-md5
|
||||
Username string `yaml:"username"` // plain & cram-md5
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allows overriding the boune address
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
|
||||
RecipientAllowlistRegexp *regexp.Regexp
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
cssFontRestriction := "*"
|
||||
if BlockRemoteCSSAndFonts {
|
||||
cssFontRestriction = "'self'"
|
||||
}
|
||||
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
|
||||
if DataFile != "" && isDir(DataFile) {
|
||||
DataFile = filepath.Join(DataFile, "mailpit.db")
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(\[([\da-fA-F:])+\])):\d+$`)
|
||||
re := regexp.MustCompile(`.*:\d+$`)
|
||||
if !re.MatchString(SMTPListen) {
|
||||
return errors.New("SMTP bind should be in the format of <ip>:<port>")
|
||||
return errors.New("[smtp] bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
if !re.MatchString(HTTPListen) {
|
||||
return errors.New("HTTP bind should be in the format of <ip>:<port>")
|
||||
return errors.New("[ui] 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)
|
||||
return fmt.Errorf("[ui] HTTP password file not found: %s", UIAuthFile)
|
||||
}
|
||||
|
||||
a, err := htpasswd.New(UIAuthFile, htpasswd.DefaultSystems, nil)
|
||||
b, err := os.ReadFile(UIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
UIAuth = a
|
||||
if err := auth.SetUIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
|
||||
return errors.New("You must provide both a UI TLS certificate and a key")
|
||||
return errors.New("[ui] you must provide both a UI TLS certificate and a key")
|
||||
}
|
||||
|
||||
if UITLSCert != "" {
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("TLS certificate not found: %s", UITLSCert)
|
||||
return fmt.Errorf("[ui] TLS certificate not found: %s", UITLSCert)
|
||||
}
|
||||
|
||||
if !isFile(UITLSKey) {
|
||||
return fmt.Errorf("TLS key not found: %s", UITLSKey)
|
||||
return fmt.Errorf("[ui] TLS key not found: %s", UITLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("You must provide both an SMTP TLS certificate and a key")
|
||||
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" {
|
||||
if !isFile(SMTPTLSCert) {
|
||||
return fmt.Errorf("SMTP TLS certificate not found: %s", SMTPTLSCert)
|
||||
return fmt.Errorf("[smtp] TLS certificate not found: %s", SMTPTLSCert)
|
||||
}
|
||||
|
||||
if !isFile(SMTPTLSKey) {
|
||||
return fmt.Errorf("SMTP TLS key not found: %s", SMTPTLSKey)
|
||||
return fmt.Errorf("[smtp] TLS key not found: %s", SMTPTLSKey)
|
||||
}
|
||||
} else if SMTPTLSRequired {
|
||||
return errors.New("[smtp] TLS cannot be required without an SMTP TLS certificate and key")
|
||||
}
|
||||
|
||||
if SMTPTLSRequired && SMTPAuthAllowInsecure {
|
||||
return errors.New("[smtp] TLS cannot be required while also allowing insecure authentication")
|
||||
}
|
||||
|
||||
if SMTPAuthFile != "" {
|
||||
if !isFile(SMTPAuthFile) {
|
||||
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
|
||||
return fmt.Errorf("[smtp] password file not found: %s", SMTPAuthFile)
|
||||
}
|
||||
|
||||
if SMTPAuthAcceptAny {
|
||||
return errors.New("SMTP authentication can either use --smtp-auth-file or --smtp-auth-accept-any")
|
||||
}
|
||||
|
||||
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
|
||||
b, err := os.ReadFile(SMTPAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SMTPAuthConfig = a
|
||||
|
||||
if err := auth.SetSMTPAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
|
||||
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
|
||||
return errors.New("[smtp] authentication cannot use both credentials and --smtp-auth-accept-any")
|
||||
}
|
||||
|
||||
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.]`)
|
||||
if SMTPTLSCert == "" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
|
||||
return errors.New("[smtp] authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.@]`)
|
||||
if validWebrootRe.MatchString(Webroot) {
|
||||
return fmt.Errorf("Invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - /]", Webroot)
|
||||
return fmt.Errorf("invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]", Webroot)
|
||||
}
|
||||
|
||||
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
|
||||
Webroot = s
|
||||
|
||||
if WebhookURL != "" && !isValidURL(WebhookURL) {
|
||||
return fmt.Errorf("webhook URL does not appear to be a valid URL (%s)", WebhookURL)
|
||||
}
|
||||
|
||||
if EnableSpamAssassin != "" {
|
||||
spamassassin.SetService(EnableSpamAssassin)
|
||||
logger.Log().Infof("[spamassassin] enabled via %s", EnableSpamAssassin)
|
||||
|
||||
if err := spamassassin.Ping(); err != nil {
|
||||
logger.Log().Warnf("[spamassassin] ping: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
SMTPTags = []AutoTag{}
|
||||
|
||||
p := shellwords.NewParser()
|
||||
|
||||
if SMTPCLITags != "" {
|
||||
args, err := p.Parse(SMTPCLITags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error parsing tags (%s)", err)
|
||||
}
|
||||
args := tools.ArgsParser(SMTPCLITags)
|
||||
|
||||
for _, a := range args {
|
||||
t := strings.Split(a, "=")
|
||||
if len(t) > 1 {
|
||||
tag := strings.TrimSpace(t[0])
|
||||
if !TagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("[tag] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
}
|
||||
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
|
||||
if len(match) == 0 {
|
||||
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
|
||||
return fmt.Errorf("[tag] invalid tag match (%s) - no search detected", tag)
|
||||
}
|
||||
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
|
||||
} else {
|
||||
return fmt.Errorf("Error parsing tags (%s)", a)
|
||||
return fmt.Errorf("[tag] error parsing tags (%s)", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPAllowedRecipients != "" {
|
||||
restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile smtp-allowed-recipients regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPAllowedRecipientsRegexp = restrictRegexp
|
||||
logger.Log().Infof("[smtp] only allowing recipients matching the following regexp: %s", SMTPAllowedRecipients)
|
||||
}
|
||||
|
||||
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ReleaseEnabled && SMTPRelayAllIncoming {
|
||||
return errors.New("SMTP relay config must be set to relay all messages")
|
||||
return errors.New("[smtp] relay config must be set to relay all messages")
|
||||
}
|
||||
|
||||
if SMTPRelayAllIncoming {
|
||||
@@ -257,7 +325,7 @@ func parseRelayConfig(c string) error {
|
||||
}
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
|
||||
return fmt.Errorf("[smtp] relay configuration not found: %s", SMTPRelayConfigFile)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
@@ -270,7 +338,7 @@ func parseRelayConfig(c string) error {
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("SMTP relay host not set")
|
||||
return errors.New("[smtp] relay host not set")
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
@@ -283,15 +351,20 @@ func parseRelayConfig(c string) error {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("SMTP relay host username or password not set for PLAIN authentication (%s)", c)
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for PLAIN authentication (%s)", c)
|
||||
}
|
||||
} else if SMTPRelayConfig.Auth == "login" {
|
||||
SMTPRelayConfig.Auth = "login"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication (%s)", c)
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
|
||||
SMTPRelayConfig.Auth = "cram-md5"
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
|
||||
return fmt.Errorf("SMTP relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
|
||||
return fmt.Errorf("[smtp] relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("SMTP relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
@@ -302,11 +375,11 @@ func parseRelayConfig(c string) error {
|
||||
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile recipient allowlist regexp: %e", err)
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
|
||||
logger.Log().Infof("[smtp] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
|
||||
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
}
|
||||
|
||||
@@ -332,3 +405,12 @@ func isDir(path string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# 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": [],
|
||||
"Bcc": [],
|
||||
"ReplyTo": [],
|
||||
"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`, `ReplyTo` - Array of Names & Address
|
||||
- `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.
|
||||
@@ -1,166 +0,0 @@
|
||||
# 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": [],
|
||||
"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
|
||||
- `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`
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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.
|
||||
|
||||
You can view the Swagger API documentation directly within Mailpit by going to `http://0.0.0.0:8025/api/v1/`.
|
||||
|
||||
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
|
||||
- [Tags](Tags.md) - Set message tags
|
||||
- [Search](Search.md) - Searching messages
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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": [],
|
||||
"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
|
||||
- `Size` - Total size of raw email in bytes
|
||||
@@ -1,27 +0,0 @@
|
||||
# Tags
|
||||
|
||||
Set message tags.
|
||||
|
||||
|
||||
---
|
||||
## Update message tags
|
||||
|
||||
Set the tags for one or more messages.
|
||||
If the tags array is empty then all tags are removed from the messages.
|
||||
|
||||
**URL** : `api/v1/tags`
|
||||
|
||||
**Method** : `PUT`
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"ids": ["<ID>","<ID>"...],
|
||||
"tags": ["<tag>","<tag>"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
**Status** : `200`
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
@@ -14,6 +14,11 @@ const ctx = await esbuild.context(
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
loader: {
|
||||
|
||||
67
go.mod
67
go.mod
@@ -1,63 +1,70 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.18
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
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.11.1
|
||||
github.com/k3a/html2text v1.2.1
|
||||
github.com/klauspost/compress v1.16.5
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jhillyerd/enmime v1.1.0
|
||||
github.com/klauspost/compress v1.17.5
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/mattn/go-shellwords v1.0.12
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/sirupsen/logrus v1.9.2
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/mhale/smtpd v0.8.2
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.1
|
||||
golang.org/x/text v0.9.0
|
||||
github.com/tg123/go-htpasswd v1.2.2
|
||||
github.com/vanng822/go-premailer v1.20.2
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/time v0.5.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.22.1
|
||||
modernc.org/sqlite v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cznic/ql v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/tools v0.9.1 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.18.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.6 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.41.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
170
go.sum
170
go.sum
@@ -4,11 +4,18 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcv
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
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/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/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=
|
||||
@@ -39,34 +46,34 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
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/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.11.1 h1:U6ToGVxfxNQQhKrAaGxtwOf7Zqksb8AQ3j1CyAWOk5k=
|
||||
github.com/jhillyerd/enmime v0.11.1/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
|
||||
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.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
|
||||
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
|
||||
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||
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.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
|
||||
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E=
|
||||
github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -76,42 +83,39 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
|
||||
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
|
||||
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mhale/smtpd v0.8.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
|
||||
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/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/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
|
||||
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
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/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
@@ -119,64 +123,86 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
|
||||
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
|
||||
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
|
||||
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
|
||||
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/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
|
||||
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
|
||||
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
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-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
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-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
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/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
|
||||
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
@@ -188,24 +214,24 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.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 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
|
||||
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
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.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
|
||||
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
69
internal/auth/auth.go
Normal file
69
internal/auth/auth.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package auth handles the web UI and SMTP authentication
|
||||
package auth
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tg123/go-htpasswd"
|
||||
)
|
||||
|
||||
var (
|
||||
// UICredentials passwords
|
||||
UICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
)
|
||||
|
||||
// SetUIAuth will set Basic Auth credentials required for the UI & API
|
||||
func SetUIAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
UICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSMTPAuth will set SMTP credentials
|
||||
func SetSMTPAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
SMTPCredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialsFromString(s string) []string {
|
||||
// split string by any whitespace character
|
||||
re := regexp.MustCompile(`\s+`)
|
||||
|
||||
words := re.Split(s, -1)
|
||||
credentials := []string{}
|
||||
for _, w := range words {
|
||||
if w != "" {
|
||||
credentials = append(credentials, w)
|
||||
}
|
||||
}
|
||||
|
||||
return credentials
|
||||
}
|
||||
82
internal/html2text/html2text.go
Normal file
82
internal/html2text/html2text.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package html2text is a simple library to convert HTML to plain text
|
||||
package html2text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
spaceRe = regexp.MustCompile(`(?mi)<\/(div|p|td|th|h[1-6]|ul|ol|li|address|article|aside|blockquote|dl|dt|footer|header|hr|main|nav|pre|table|thead|tfoot|video)><`)
|
||||
brRe = regexp.MustCompile(`(?mi)<(br /|br)>`)
|
||||
imgRe = regexp.MustCompile(`(?mi)<(img)`)
|
||||
skip = make(map[string]bool)
|
||||
)
|
||||
|
||||
func init() {
|
||||
skip["script"] = true
|
||||
skip["title"] = true
|
||||
skip["head"] = true
|
||||
skip["link"] = true
|
||||
skip["meta"] = true
|
||||
skip["style"] = true
|
||||
skip["noscript"] = true
|
||||
}
|
||||
|
||||
// Strip will convert a HTML string to plain text
|
||||
func Strip(h string, includeLinks bool) string {
|
||||
h = spaceRe.ReplaceAllString(h, "</$1> <")
|
||||
h = brRe.ReplaceAllString(h, " ")
|
||||
h = imgRe.ReplaceAllString(h, " <$1")
|
||||
var buffer bytes.Buffer
|
||||
doc, err := html.Parse(strings.NewReader(h))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
extract(doc, &buffer, includeLinks)
|
||||
return clean(buffer.String())
|
||||
}
|
||||
|
||||
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
|
||||
if node.Type == html.TextNode {
|
||||
data := node.Data
|
||||
if data != "" {
|
||||
buff.WriteString(data)
|
||||
}
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
if _, skip := skip[c.Data]; !skip {
|
||||
if includeLinks && c.Data == "a" {
|
||||
for _, a := range c.Attr {
|
||||
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
|
||||
buff.WriteString(" " + a.Val + " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
extract(c, buff, includeLinks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clean(text string) string {
|
||||
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
|
||||
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
|
||||
|
||||
// remove non-printable characters
|
||||
text = strings.Map(func(r rune) rune {
|
||||
if unicode.IsPrint(r) {
|
||||
return r
|
||||
}
|
||||
return []rune(" ")[0]
|
||||
}, text)
|
||||
|
||||
text = re.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
250
internal/html2text/html2text_test.go
Normal file
250
internal/html2text/html2text_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package html2text
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlain(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
tests["this is a test"] = "this is a test"
|
||||
tests["thiS IS a Test"] = "thiS IS a Test"
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
|
||||
tests["<h1>This is a test.</h1> "] = "This is a test."
|
||||
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
|
||||
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
|
||||
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
|
||||
tests[`<h1>Heading</h1>
|
||||
<p>Paragraph</p>`] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text"
|
||||
// broken html
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, false)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLinks(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
tests["this is a test"] = "this is a test"
|
||||
tests["thiS IS a Test"] = "thiS IS a Test"
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
|
||||
tests["<h1>This is a test.</h1> "] = "This is a test."
|
||||
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
|
||||
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
|
||||
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1>
|
||||
<p>Paragraph</p>`] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
|
||||
// broken html
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
|
||||
|
||||
for str, expected := range tests {
|
||||
res := Strip(str, true)
|
||||
if res != expected {
|
||||
t.Log("error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPlain(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Strip(htmlTestData, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLinks(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Strip(htmlTestData, true)
|
||||
}
|
||||
}
|
||||
|
||||
var htmlTestData = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="font-family: sans-serif; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; box-sizing: border-box;" xml:lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>[axllent/mailpit] Run failed: .github/workflows/tests.yml - feature/swagger (284335a)</title>
|
||||
|
||||
</head>
|
||||
<body style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; font-size: 14px; line-height: 1.5; color: #24292e; background-color: #fff; margin: 0;" bgcolor="#fff">
|
||||
<table align="center" class="container-sm width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 544px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td class="center p-3" align="center" valign="top" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 16px;">
|
||||
<center style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full container-md" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 768px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="left" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td class="text-left" style="box-sizing: border-box; text-align: left !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;" align="left">
|
||||
<img src="https://github.githubassets.com/images/email/global/octocat-logo.png" alt="GitHub" width="32" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; border-style: none;" />
|
||||
<h2 class="lh-condensed mt-2 text-normal" style="box-sizing: border-box; margin-top: 8px !important; margin-bottom: 0; font-size: 24px; font-weight: 400 !important; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
[axllent/mailpit] .github/workflows/tests.yml workflow run
|
||||
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table width="100%" class="width-full" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td class="border rounded-2 d-block" style="box-sizing: border-box; border-radius: 6px !important; display: block !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0; border: 1px solid #e1e4e8;">
|
||||
<table align="center" class="width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
|
||||
<table align="center" class="border-bottom width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; border-bottom-width: 1px !important; border-bottom-color: #e1e4e8 !important; border-bottom-style: solid !important; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td class="d-block px-3 pt-3 p-sm-4" style="box-sizing: border-box; display: block !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 24px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
|
||||
<img src="https://github.githubassets.com/images/email/icons/actions.png" width="56" height="56" alt="" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; border-style: none;" />
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="12" style="font-size: 12px; line-height: 12px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="lh-condensed" style="box-sizing: border-box; margin-top: 0; margin-bottom: 0; font-size: 20px; font-weight: 600; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">.github/workflows/tests.yml: No jobs were run</h3>
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<!--[if mso]> <table><tr><td align="center" bgcolor="#28a745"> <![endif]-->
|
||||
<a href="https://github.com/axllent/mailpit/actions/runs/6522820865" target="_blank" rel="noopener noreferrer" class="btn btn-large btn-primary" style="background-color: #1f883d !important; box-sizing: border-box; color: #fff; text-decoration: none; position: relative; display: inline-block; font-size: inherit; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: middle; cursor: pointer; -webkit-user-select: none; user-select: none; border-radius: .5em; -webkit-appearance: none; appearance: none; box-shadow: 0 1px 0 rgba(27,31,35,.1),inset 0 1px 0 rgba(255,255,255,.03); transition: background-color .2s cubic-bezier(0.3, 0, 0.5, 1); font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: .75em 1.5em; border: 1px solid #1f883d;">View workflow run</a>
|
||||
<!--[if mso]> </td></tr></table> <![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="32" style="font-size: 32px; line-height: 32px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="f5 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 14px !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;"> </p><p style="font-size: small; -webkit-text-size-adjust: none; color: #666; box-sizing: border-box; margin-top: 0; margin-bottom: 10px; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">—<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;" />You are receiving this because you are subscribed to this thread.<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;" /><a href="https://github.com/settings/notifications" style="background-color: transparent; box-sizing: border-box; color: #0366d6; text-decoration: none; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">Manage your GitHub Actions notifications</a></p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;">
|
||||
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">
|
||||
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important; padding: 0;"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="f6 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 12px !important; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji" !important;">GitHub, Inc. ・88 Colin P Kelly Jr Street ・San Francisco, CA 94107</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- prevent Gmail on iOS font size manipulation -->
|
||||
<div style="display: none; white-space: nowrap; box-sizing: border-box; font: 15px/0 apple-system, BlinkMacSystemFont, "Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";">                                                             </div>
|
||||
</body>
|
||||
</html>`
|
||||
5
internal/htmlcheck/README.md
Normal file
5
internal/htmlcheck/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# HTML check
|
||||
|
||||
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
|
||||
|
||||
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)
|
||||
4342
internal/htmlcheck/caniemail-data.json
Normal file
4342
internal/htmlcheck/caniemail-data.json
Normal file
File diff suppressed because it is too large
Load Diff
74
internal/htmlcheck/caniemail.go
Normal file
74
internal/htmlcheck/caniemail.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package htmlcheck is used for parsing HTML and returning
|
||||
// HTML compatibility errors and warnings
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
//go:embed caniemail-data.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
var (
|
||||
cie = CanIEmail{}
|
||||
|
||||
noteMatch = regexp.MustCompile(` #(\d)+$`)
|
||||
|
||||
// LimitFamilies will limit results to families if set
|
||||
LimitFamilies = []string{}
|
||||
|
||||
// LimitPlatforms will limit results to platforms if set
|
||||
LimitPlatforms = []string{}
|
||||
|
||||
// LimitClients will limit results to clients if set
|
||||
LimitClients = []string{}
|
||||
)
|
||||
|
||||
// CanIEmail struct for JSON data
|
||||
type CanIEmail struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
LastUpdateDate string `json:"last_update_date"`
|
||||
// NiceNames map[string]string `json:"last_update_date"`
|
||||
NiceNames struct {
|
||||
Family map[string]string `json:"family"`
|
||||
Platform map[string]string `json:"platform"`
|
||||
Support map[string]string `json:"support"`
|
||||
Category map[string]string `json:"category"`
|
||||
} `json:"nicenames"`
|
||||
Data []JSONResult `json:"data"`
|
||||
}
|
||||
|
||||
// JSONResult struct for CanIEmail Data
|
||||
type JSONResult struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
}
|
||||
|
||||
// Load the JSON data
|
||||
func loadJSONData() error {
|
||||
if cie.APIVersion != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := embeddedFS.ReadFile("caniemail-data.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cie = CanIEmail{}
|
||||
|
||||
return json.Unmarshal(b, &cie)
|
||||
}
|
||||
204
internal/htmlcheck/config.go
Normal file
204
internal/htmlcheck/config.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package htmlcheck
|
||||
|
||||
import "regexp"
|
||||
|
||||
// HTML tests
|
||||
var htmlTests = map[string]string{
|
||||
// body check is manually done because it always exists in *goquery.Document
|
||||
"html-body": "body",
|
||||
// HTML tests
|
||||
"html-object": "object, embed, image, pdf",
|
||||
"html-link": "link",
|
||||
"html-hr": "hr",
|
||||
"html-dialog": "dialog",
|
||||
"html-srcset": "[srcset]",
|
||||
"html-picture": "picture",
|
||||
"html-svg": "svg",
|
||||
"html-progress": "progress",
|
||||
"html-required": "[required]",
|
||||
"html-meter": "meter",
|
||||
"html-audio": "audio",
|
||||
"html-form": "form",
|
||||
"html-input-submit": "submit",
|
||||
"html-button-reset": "button[type=\"reset\"]",
|
||||
"html-button-submit": "submit, button[type=\"submit\"]",
|
||||
"html-base": "base",
|
||||
"html-input-checkbox": "checkbox",
|
||||
"html-input-hidden": "[type=\"hidden\"]",
|
||||
"html-input-radio": "radio",
|
||||
"html-input-text": "input[type=\"text\"]",
|
||||
"html-video": "video",
|
||||
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
|
||||
"html-select": "select",
|
||||
"html-textarea": "textarea",
|
||||
"html-anchor-links": "a[href^=\"#\"]",
|
||||
"html-style": "style",
|
||||
"html-image-maps": "map, img[usemap]",
|
||||
}
|
||||
|
||||
// Image tests using regex to match against img[src]
|
||||
var imageRegexpTests = map[string]*regexp.Regexp{
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
|
||||
}
|
||||
|
||||
var cssInlineTests = map[string]string{
|
||||
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
|
||||
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
|
||||
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
|
||||
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
|
||||
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
|
||||
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
|
||||
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
|
||||
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
|
||||
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
|
||||
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
|
||||
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
|
||||
"css-background": "[style*=\"background:\"], [background]", // 57.407406
|
||||
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
|
||||
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
|
||||
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
|
||||
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
|
||||
"css-border": "[style*=\"border:\"], [border]", // 86.95652
|
||||
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
|
||||
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
|
||||
"css-caption-side": "[style*=\"caption-side:\"]", // 84
|
||||
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
|
||||
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
|
||||
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
|
||||
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
|
||||
"css-direction": "[style*=\"direction:\"]", // 97.77778
|
||||
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
|
||||
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
|
||||
"css-display-none": "[style*=\"display:none\"]", // 84.78261
|
||||
"css-display": "[style*=\"display:\"]", // 55.555553
|
||||
"css-filter": "[style*=\"filter:\"]", // 50
|
||||
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
|
||||
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
|
||||
"css-float": "[style*=\"float:\"]", // 85.10638
|
||||
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
|
||||
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
|
||||
"css-font": "[style*=\"font:\"]", // 95.833336
|
||||
"css-gap": "[style*=\"gap:\"]", // 40
|
||||
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
|
||||
"css-height": "[style*=\"height:\"], [height]", // 77.08333
|
||||
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
|
||||
"css-important": "[style*=\"!important\"]", // 43.478264
|
||||
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
|
||||
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
|
||||
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
|
||||
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
|
||||
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
|
||||
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
|
||||
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
|
||||
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
|
||||
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
|
||||
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
|
||||
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
|
||||
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
|
||||
"css-margin": "[style*=\"margin:\"]", // 71.42857
|
||||
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
|
||||
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
|
||||
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
|
||||
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
|
||||
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
|
||||
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
|
||||
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
|
||||
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
|
||||
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
|
||||
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
|
||||
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
|
||||
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
|
||||
"css-outline": "[style*=\"outline:\"]", // 80.85106
|
||||
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
|
||||
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
|
||||
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
|
||||
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
|
||||
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
|
||||
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
|
||||
"css-position": "[style*=\"position:\"]", // 19.56522
|
||||
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
|
||||
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
|
||||
"css-rgba": "[style*=\"rgba(\"]", // 56
|
||||
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
|
||||
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
|
||||
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
|
||||
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
|
||||
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
|
||||
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
|
||||
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
|
||||
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
|
||||
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
|
||||
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
|
||||
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
|
||||
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
|
||||
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
|
||||
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
|
||||
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
|
||||
"css-transform": "[style*=\"transform:\"]", // 50
|
||||
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
|
||||
"css-variables": "[style*=\"variables:\"]", // 46.551727
|
||||
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
|
||||
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
|
||||
"css-width": "[style*=\"width:\"], [width]", // 87.5
|
||||
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
|
||||
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
|
||||
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
|
||||
}
|
||||
|
||||
// some CSS tests using regex for things that can't be merged inline
|
||||
var cssRegexpTests = map[string]*regexp.Regexp{
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
|
||||
}
|
||||
|
||||
// some CSS tests using regex for units
|
||||
var cssRegexpUnitTests = map[string]*regexp.Regexp{
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
|
||||
}
|
||||
217
internal/htmlcheck/css.go
Normal file
217
internal/htmlcheck/css.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/vanng822/go-premailer/premailer"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// Go cannot calculate any rendered CSS attributes, so we merge all styles
|
||||
// into the HTML and detect elements with styles containing the keywords.
|
||||
func runCSSTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
inlined, err := inlineRemoteCSS(html)
|
||||
if err != nil {
|
||||
inlined = html
|
||||
}
|
||||
|
||||
// merge all CSS inline
|
||||
merged, err := mergeInlineCSS(inlined)
|
||||
if err != nil {
|
||||
merged = inlined
|
||||
}
|
||||
|
||||
reader := strings.NewReader(merged)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
for key, test := range cssInlineTests {
|
||||
totalTests++
|
||||
found := len(doc.Find(test).Nodes)
|
||||
if found > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
allNodeStyles := []string{}
|
||||
for _, n := range doc.Find("*[style]").Nodes {
|
||||
style, err := tools.GetHTMLAttributeVal(n, "style")
|
||||
if err == nil {
|
||||
allNodeStyles = append(allNodeStyles, style)
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpUnitTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := 0
|
||||
// loop through all styles to count total
|
||||
for _, styles := range allNodeStyles {
|
||||
found = found + len(re.FindAllString(styles, -1))
|
||||
}
|
||||
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get all inline CSS block data
|
||||
reader = strings.NewReader(inlined)
|
||||
|
||||
// Load the HTML document
|
||||
doc, _ = goquery.NewDocumentFromReader(reader)
|
||||
|
||||
cssCode := ""
|
||||
for _, n := range doc.Find("style").Nodes {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
cssCode = cssCode + c.Data
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := len(re.FindAllString(cssCode, -1))
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
|
||||
// MergeInlineCSS merges header CSS into element attributes
|
||||
func mergeInlineCSS(html string) (string, error) {
|
||||
options := premailer.NewOptions()
|
||||
// options.RemoveClasses = true
|
||||
// options.CssToAttributes = false
|
||||
options.KeepBangImportant = true
|
||||
pre, err := premailer.NewPremailerFromString(html, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pre.Transform()
|
||||
}
|
||||
|
||||
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
|
||||
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
|
||||
func inlineRemoteCSS(h string) (string, error) {
|
||||
reader := strings.NewReader(h)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range remoteCSS {
|
||||
attributes := link.Attr
|
||||
for _, a := range attributes {
|
||||
if a.Key == "href" {
|
||||
if !isURL(a.Val) {
|
||||
// skip invalid URL
|
||||
continue
|
||||
}
|
||||
|
||||
if config.BlockRemoteCSSAndFonts {
|
||||
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
// create new <style> block and insert downloaded CSS
|
||||
styleBlock := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "style",
|
||||
DataAtom: atom.Style,
|
||||
}
|
||||
styleBlock.AppendChild(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: string(resp),
|
||||
})
|
||||
|
||||
link.Parent.AppendChild(styleBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newDoc, err := doc.Html()
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[html-check] failed to download %s", err.Error())
|
||||
return h, err
|
||||
}
|
||||
|
||||
return newDoc, nil
|
||||
}
|
||||
|
||||
// DownloadToBytes returns a []byte slice from a URL
|
||||
func downloadToBytes(url string) ([]byte, error) {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
102
internal/htmlcheck/html.go
Normal file
102
internal/htmlcheck/html.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// HTML tests
|
||||
func runHTMLTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
reader := strings.NewReader(html)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
// Almost all <script> is bad
|
||||
scripts := len(doc.Find("script:not([type=\"application/ld+json\"]):not([type=\"application/json\"])").Nodes)
|
||||
if scripts > 0 {
|
||||
var result = Warning{}
|
||||
result.Title = "<script> element"
|
||||
result.Slug = "html-script"
|
||||
result.Category = "html"
|
||||
result.Description = "JavaScript is not supported in any email client."
|
||||
result.Tags = []string{}
|
||||
result.Results = []Result{}
|
||||
result.NotesByNumber = map[string]string{}
|
||||
result.Score.Found = scripts
|
||||
result.Score.Supported = 0
|
||||
result.Score.Partial = 0
|
||||
result.Score.Unsupported = 100
|
||||
results = append(results, result)
|
||||
totalTests++
|
||||
}
|
||||
|
||||
for key, test := range htmlTests {
|
||||
totalTests++
|
||||
if test == "body" {
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
if re.MatchString(html) {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
result.Score.Found = 1
|
||||
results = append(results, result)
|
||||
}
|
||||
} else if len(doc.Find(test).Nodes) > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
totalTests++
|
||||
|
||||
result.Score.Found = len(doc.Find(test).Nodes)
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// find all images
|
||||
images := doc.Find("img[src]").Nodes
|
||||
imageResults := make(map[string]int)
|
||||
totalTests = totalTests + len(imageRegexpTests)
|
||||
|
||||
for _, image := range images {
|
||||
src, err := tools.GetHTMLAttributeVal(image, "src")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for key, test := range imageRegexpTests {
|
||||
if test.MatchString(src) {
|
||||
matches, exists := imageResults[key]
|
||||
if exists {
|
||||
imageResults[key] = matches + 1
|
||||
} else {
|
||||
imageResults[key] = 1
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, found := range imageResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
212
internal/htmlcheck/main.go
Normal file
212
internal/htmlcheck/main.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(html string) (Response, error) {
|
||||
s := Response{}
|
||||
s.Warnings = []Warning{}
|
||||
if platforms, err := Platforms(); err == nil {
|
||||
s.Platforms = platforms
|
||||
}
|
||||
|
||||
s.Total = Total{}
|
||||
|
||||
// crude way to determine whether the HTML contains a <body> structure
|
||||
// or whether it's just plain HTML content
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
nodeMatch := "body *, script"
|
||||
if re.MatchString(html) {
|
||||
nodeMatch = "*:not(html):not(head):not(meta), script"
|
||||
}
|
||||
reader := strings.NewReader(html)
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
// calculate the number of nodes in HTML
|
||||
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// HTML tests
|
||||
htmlResults, totalTests, err := runHTMLTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add html test totals
|
||||
s.Warnings = append(s.Warnings, htmlResults...)
|
||||
|
||||
// CSS tests
|
||||
cssResults, totalTests, err := runCSSTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add css test totals
|
||||
s.Warnings = append(s.Warnings, cssResults...)
|
||||
|
||||
// calculate total score
|
||||
var partial, unsupported float32
|
||||
partial = 0
|
||||
unsupported = 0
|
||||
|
||||
for _, w := range s.Warnings {
|
||||
if w.Score.Found == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// supported is calculated by subtracting partial and unsupported from 100%
|
||||
if w.Score.Partial > 0 {
|
||||
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > partial {
|
||||
partial = weighted
|
||||
}
|
||||
}
|
||||
if w.Score.Unsupported > 0 {
|
||||
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > unsupported {
|
||||
unsupported = weighted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Total.Supported = 100 - partial - unsupported
|
||||
s.Total.Partial = partial
|
||||
s.Total.Unsupported = unsupported
|
||||
|
||||
// sort slice to get lowest scores first
|
||||
sort.Slice(s.Warnings, func(i, j int) bool {
|
||||
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
|
||||
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Test returns a test
|
||||
func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
warning := Warning{}
|
||||
exists := false
|
||||
found := JSONResult{}
|
||||
for _, r := range cie.Data {
|
||||
if r.Slug == k {
|
||||
found = r
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return warning, fmt.Errorf("%s does not exist", k)
|
||||
}
|
||||
|
||||
warning.Slug = found.Slug
|
||||
warning.Title = found.Title
|
||||
warning.Description = mdToHTML(found.Description)
|
||||
warning.Category = found.Category
|
||||
warning.URL = found.URL
|
||||
warning.Tags = found.Tags
|
||||
// warning.Keywords = found.Keywords
|
||||
// warning.Notes = found.Notes
|
||||
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
|
||||
for nr, note := range found.NotesByNumber {
|
||||
warning.NotesByNumber[nr] = mdToHTML(note)
|
||||
}
|
||||
warning.Results = []Result{}
|
||||
|
||||
var y, n, p float32
|
||||
|
||||
for family, stats := range found.Stats {
|
||||
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
s := Result{}
|
||||
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
|
||||
s.Family = family
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
warning.Results = append(warning.Results, s)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
total := y + n + p
|
||||
warning.Score.Supported = y / total * 100
|
||||
warning.Score.Unsupported = n / total * 100
|
||||
warning.Score.Partial = p / total * 100
|
||||
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func inArray(n string, h []string) bool {
|
||||
n = strings.ToLower(n)
|
||||
|
||||
for _, v := range h {
|
||||
if strings.ToLower(v) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert markdown to HTML, stripping <p> & </p>
|
||||
func mdToHTML(str string) string {
|
||||
md := []byte(str)
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
// extensions := parser.NoExtensions
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse(md)
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
|
||||
}
|
||||
38
internal/htmlcheck/platforms.go
Normal file
38
internal/htmlcheck/platforms.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package htmlcheck
|
||||
|
||||
import "sort"
|
||||
|
||||
// Platforms returns all platforms with their respective email clients
|
||||
func Platforms() (map[string][]string, error) {
|
||||
// [platform]clients
|
||||
data := make(map[string][]string)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
for _, t := range cie.Data {
|
||||
for family, stats := range t.Stats {
|
||||
niceFamily := cie.NiceNames.Family[family]
|
||||
for platform := range stats.(map[string]interface{}) {
|
||||
c, found := data[platform]
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
}
|
||||
if !inArray(niceFamily, c) {
|
||||
c = append(c, niceFamily)
|
||||
data[platform] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group, clients := range data {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i] < clients[j]
|
||||
})
|
||||
data[group] = clients
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
87
internal/htmlcheck/structs.go
Normal file
87
internal/htmlcheck/structs.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package htmlcheck
|
||||
|
||||
// Response represents the HTML check response struct
|
||||
//
|
||||
// swagger:model HTMLCheckResponse
|
||||
type Response struct {
|
||||
// List of warnings from tests
|
||||
Warnings []Warning `json:"Warnings"`
|
||||
// All platforms tested, mainly for the web UI
|
||||
Platforms map[string][]string `json:"Platforms"`
|
||||
// Total overall score
|
||||
Total Total `json:"Total"`
|
||||
}
|
||||
|
||||
// Warning represents a failed test
|
||||
//
|
||||
// swagger:model HTMLCheckWarning
|
||||
type Warning struct {
|
||||
// Slug identifier
|
||||
Slug string `json:"Slug"`
|
||||
// Friendly title
|
||||
Title string `json:"Title"`
|
||||
// Description
|
||||
Description string `json:"Description"`
|
||||
// URL to caniemail.com
|
||||
URL string `json:"URL"`
|
||||
// Category [css, html]
|
||||
Category string `json:"Category"`
|
||||
// Tags
|
||||
Tags []string `json:"Tags"`
|
||||
// Keywords
|
||||
Keywords string `json:"Keywords"`
|
||||
// Test results
|
||||
Results []Result `json:"Results"`
|
||||
// Notes based on results
|
||||
NotesByNumber map[string]string `json:"NotesByNumber"`
|
||||
// Test score calculated from results
|
||||
Score Score `json:"Score"`
|
||||
}
|
||||
|
||||
// Result struct
|
||||
//
|
||||
// swagger:model HTMLCheckResult
|
||||
type Result struct {
|
||||
// Friendly name of result, combining family, platform & version
|
||||
Name string `json:"Name"`
|
||||
// Platform eg: ios, android, windows
|
||||
Platform string `json:"Platform"`
|
||||
// Family eg: Outlook, Mozilla Thunderbird
|
||||
Family string `json:"Family"`
|
||||
// Family version eg: 4.7.1, 2019-10, 10.3
|
||||
Version string `json:"Version"`
|
||||
// Support [yes, no, partial]
|
||||
Support string `json:"Support"`
|
||||
// Note number for partially supported if applicable
|
||||
NoteNumber string `json:"NoteNumber"` // where applicable
|
||||
}
|
||||
|
||||
// Score struct
|
||||
//
|
||||
// swagger:model HTMLCheckScore
|
||||
type Score struct {
|
||||
// Number of matches in the document
|
||||
Found int `json:"Found"`
|
||||
// Total percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Total percentage partially supported
|
||||
Partial float32 `json:"Partial"`
|
||||
// Total percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"`
|
||||
}
|
||||
|
||||
// Total weighted result for all scores
|
||||
//
|
||||
// swagger:model HTMLCheckTotal
|
||||
type Total struct {
|
||||
// Total number of tests done
|
||||
Tests int `json:"Tests"`
|
||||
// Total number of HTML nodes detected in message
|
||||
Nodes int `json:"Nodes"`
|
||||
// Overall percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Overall percentage partially supported
|
||||
Partial float32 `json:"Partial"` // total percentage
|
||||
// Overall percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"` // total percentage
|
||||
}
|
||||
92
internal/linkcheck/main.go
Normal file
92
internal/linkcheck/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package linkcheck handles message links checking
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
s := Response{}
|
||||
|
||||
allLinks := extractHTMLLinks(msg)
|
||||
allLinks = strUnique(append(allLinks, extractTextLinks(msg)...))
|
||||
s.Links = getHTTPStatuses(allLinks, followRedirects)
|
||||
|
||||
for _, l := range s.Links {
|
||||
if l.StatusCode >= 400 || l.StatusCode == 0 {
|
||||
s.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func extractTextLinks(msg *storage.Message) []string {
|
||||
links := []string{}
|
||||
|
||||
for _, match := range linkRe.FindAllString(msg.Text, -1) {
|
||||
links = append(links, match)
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
func extractHTMLLinks(msg *storage.Message) []string {
|
||||
links := []string{}
|
||||
|
||||
reader := strings.NewReader(msg.HTML)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return links
|
||||
}
|
||||
|
||||
aLinks := doc.Find("a[href]").Nodes
|
||||
for _, link := range aLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "href")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range cssLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "href")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
imgLinks := doc.Find("img[src]").Nodes
|
||||
for _, link := range imgLinks {
|
||||
l, err := tools.GetHTMLAttributeVal(link, "src")
|
||||
if err == nil && linkRe.MatchString(l) {
|
||||
links = append(links, l)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
// strUnique return a slice of unique strings from a slice
|
||||
func strUnique(strSlice []string) []string {
|
||||
keys := make(map[string]bool)
|
||||
list := []string{}
|
||||
for _, entry := range strSlice {
|
||||
if _, value := keys[entry]; !value {
|
||||
keys[entry] = true
|
||||
list = append(list, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
114
internal/linkcheck/status.go
Normal file
114
internal/linkcheck/status.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
// allow 5 threads
|
||||
threads := make(chan int, 5)
|
||||
|
||||
results := make(map[string]Link, len(links))
|
||||
resultsMutex := sync.RWMutex{}
|
||||
|
||||
output := []Link{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, l := range links {
|
||||
wg.Add(1)
|
||||
go func(link string, w *sync.WaitGroup) {
|
||||
threads <- 1 // will block if MAX threads
|
||||
defer w.Done()
|
||||
|
||||
code, err := doHead(link, followRedirects)
|
||||
l := Link{}
|
||||
l.URL = link
|
||||
if err != nil {
|
||||
l.StatusCode = 0
|
||||
l.Status = httpErrorSummary(err)
|
||||
} else {
|
||||
l.StatusCode = code
|
||||
l.Status = http.StatusText(code)
|
||||
}
|
||||
resultsMutex.Lock()
|
||||
results[link] = l
|
||||
resultsMutex.Unlock()
|
||||
|
||||
<-threads // remove from threads
|
||||
}(l, &wg)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for _, l := range results {
|
||||
output = append(output, l)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// Do a HEAD request to return HTTP status code
|
||||
func doHead(link string, followRedirects bool) (int, error) {
|
||||
|
||||
timeout := time.Duration(10 * time.Second)
|
||||
|
||||
tr := &http.Transport{}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if followRedirects {
|
||||
return nil
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("HEAD", link, nil)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[link-check] %s", err.Error())
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
return res.StatusCode, err
|
||||
}
|
||||
|
||||
return 0, err
|
||||
|
||||
}
|
||||
|
||||
return res.StatusCode, nil
|
||||
}
|
||||
|
||||
// HTTP errors include a lot more info that just the actual error, so this
|
||||
// tries to take the final part of it, eg: `no such host`
|
||||
func httpErrorSummary(err error) string {
|
||||
var re = regexp.MustCompile(`.*: (.*)$`)
|
||||
|
||||
e := err.Error()
|
||||
if !re.MatchString(e) {
|
||||
return e
|
||||
}
|
||||
|
||||
parts := re.FindAllStringSubmatch(e, -1)
|
||||
|
||||
return parts[0][len(parts[0])-1]
|
||||
}
|
||||
21
internal/linkcheck/structs.go
Normal file
21
internal/linkcheck/structs.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package linkcheck
|
||||
|
||||
// Response represents the Link check response
|
||||
//
|
||||
// swagger:model LinkCheckResponse
|
||||
type Response struct {
|
||||
// Total number of errors
|
||||
Errors int `json:"Errors"`
|
||||
// Tested links
|
||||
Links []Link `json:"Links"`
|
||||
}
|
||||
|
||||
// Link struct
|
||||
type Link struct {
|
||||
// Link URL
|
||||
URL string `json:"URL"`
|
||||
// HTTP status code
|
||||
StatusCode int `json:"StatusCode"`
|
||||
// HTTP status definition
|
||||
Status string `json:"Status"`
|
||||
}
|
||||
@@ -18,6 +18,8 @@ var (
|
||||
QuietLogging bool
|
||||
// NoLogging shows only fatal errors
|
||||
NoLogging bool
|
||||
// LogFile sets a log file
|
||||
LogFile string
|
||||
)
|
||||
|
||||
// Log returns the logger instance
|
||||
@@ -36,11 +38,21 @@ func Log() *logrus.Logger {
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
}
|
||||
|
||||
log.Out = os.Stdout
|
||||
if LogFile != "" {
|
||||
file, err := os.OpenFile(LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664)
|
||||
if err == nil {
|
||||
log.Out = file
|
||||
} else {
|
||||
log.Out = os.Stdout
|
||||
log.Warn("Failed to log to file, using default stderr")
|
||||
}
|
||||
} else {
|
||||
log.Out = os.Stdout
|
||||
}
|
||||
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006/01/02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
}
|
||||
|
||||
100
internal/spamassassin/postmark/postmark.go
Normal file
100
internal/spamassassin/postmark/postmark.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Package postmark uses the free https://spamcheck.postmarkapp.com/
|
||||
// See https://spamcheck.postmarkapp.com/doc/ for more details.
|
||||
package postmark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Response struct
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"` // for errors only
|
||||
Score string `json:"score"`
|
||||
Rules []Rule `json:"rules"`
|
||||
Report string `json:"report"` // ignored
|
||||
}
|
||||
|
||||
// Rule struct
|
||||
type Rule struct {
|
||||
Score string `json:"score"`
|
||||
// Name not returned by postmark but rather extracted from description
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Check will post the email data to Postmark
|
||||
func Check(email []byte, timeout int) (Response, error) {
|
||||
r := Response{}
|
||||
// '{"email":"raw dump of email", "options":"short"}'
|
||||
var d struct {
|
||||
// The raw dump of the email to be filtered, including all headers.
|
||||
Email string `json:"email"`
|
||||
// Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request.
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
d.Email = string(email)
|
||||
d.Options = "long"
|
||||
|
||||
data, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: time.Duration(timeout) * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json",
|
||||
bytes.NewBuffer(data))
|
||||
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&r)
|
||||
|
||||
// remove trailing line spaces for all lines in report
|
||||
re := regexp.MustCompile("\r?\n")
|
||||
lines := re.Split(r.Report, -1)
|
||||
reportLines := []string{}
|
||||
for _, l := range lines {
|
||||
line := strings.TrimRight(l, " ")
|
||||
reportLines = append(reportLines, line)
|
||||
}
|
||||
reportRaw := strings.Join(reportLines, "\n")
|
||||
|
||||
// join description lines to make a single line per rule
|
||||
re2 := regexp.MustCompile("\n ")
|
||||
report := re2.ReplaceAllString(reportRaw, "")
|
||||
for i, rule := range r.Rules {
|
||||
// populate rule name
|
||||
r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report)
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
// Extract the name of the test from the report as Postmark does not include this in the JSON reports
|
||||
func nameFromReport(score, description, report string) string {
|
||||
score = regexp.QuoteMeta(score)
|
||||
description = regexp.QuoteMeta(description)
|
||||
str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description)
|
||||
re := regexp.MustCompile(str)
|
||||
|
||||
matches := re.FindAllStringSubmatch(report, 1)
|
||||
if len(matches) > 0 && len(matches[0]) == 2 {
|
||||
return strings.TrimSpace(matches[0][1])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
147
internal/spamassassin/spamassassin.go
Normal file
147
internal/spamassassin/spamassassin.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package spamassassin will return results from either a SpamAssassin server or
|
||||
// Postmark's public API depending on configuration
|
||||
package spamassassin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/spamassassin/postmark"
|
||||
"github.com/axllent/mailpit/internal/spamassassin/spamc"
|
||||
)
|
||||
|
||||
var (
|
||||
// Service to use, either "<host>:<ip>" for self-hosted SpamAssassin or "postmark"
|
||||
service string
|
||||
|
||||
// SpamScore is the score at which a message is determined to be spam
|
||||
spamScore = 5.0
|
||||
|
||||
// Timeout in seconds
|
||||
timeout = 8
|
||||
)
|
||||
|
||||
// Result is a SpamAssassin result
|
||||
//
|
||||
// swagger:model SpamAssassinResponse
|
||||
type Result struct {
|
||||
// Whether the message is spam or not
|
||||
IsSpam bool
|
||||
// If populated will return an error string
|
||||
Error string
|
||||
// Total spam score based on triggered rules
|
||||
Score float64
|
||||
// Spam rules triggered
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
// Rule struct
|
||||
type Rule struct {
|
||||
// Spam rule score
|
||||
Score float64
|
||||
// SpamAssassin rule name
|
||||
Name string
|
||||
// SpamAssassin rule description
|
||||
Description string
|
||||
}
|
||||
|
||||
// SetService defines which service should be used.
|
||||
func SetService(s string) {
|
||||
switch s {
|
||||
case "postmark":
|
||||
service = "postmark"
|
||||
default:
|
||||
service = s
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout defines the timeout
|
||||
func SetTimeout(t int) {
|
||||
if t > 0 {
|
||||
timeout = t
|
||||
}
|
||||
}
|
||||
|
||||
// Ping returns whether a service is active or not
|
||||
func Ping() error {
|
||||
if service == "postmark" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
}
|
||||
|
||||
return client.Ping()
|
||||
}
|
||||
|
||||
// Check will return a Result
|
||||
func Check(msg []byte) (Result, error) {
|
||||
r := Result{Score: 0}
|
||||
|
||||
if service == "" {
|
||||
return r, errors.New("no SpamAssassin service defined")
|
||||
}
|
||||
|
||||
if service == "postmark" {
|
||||
res, err := postmark.Check(msg, timeout)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r, nil
|
||||
}
|
||||
resFloat, err := strconv.ParseFloat(res.Score, 32)
|
||||
if err == nil {
|
||||
r.Score = round1dm(resFloat)
|
||||
r.IsSpam = resFloat >= spamScore
|
||||
}
|
||||
r.Error = res.Message
|
||||
for _, pr := range res.Rules {
|
||||
rule := Rule{}
|
||||
value, err := strconv.ParseFloat(pr.Score, 32)
|
||||
if err == nil {
|
||||
rule.Score = round1dm(value)
|
||||
}
|
||||
rule.Name = pr.Name
|
||||
rule.Description = pr.Description
|
||||
r.Rules = append(r.Rules, rule)
|
||||
}
|
||||
} else {
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
}
|
||||
|
||||
res, err := client.Report(msg)
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r, nil
|
||||
}
|
||||
r.IsSpam = res.Score >= spamScore
|
||||
r.Score = round1dm(res.Score)
|
||||
r.Rules = []Rule{}
|
||||
for _, sr := range res.Rules {
|
||||
rule := Rule{}
|
||||
value, err := strconv.ParseFloat(sr.Points, 32)
|
||||
if err == nil {
|
||||
rule.Score = round1dm(value)
|
||||
}
|
||||
rule.Name = sr.Name
|
||||
rule.Description = sr.Description
|
||||
r.Rules = append(r.Rules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Round to one decimal place
|
||||
func round1dm(n float64) float64 {
|
||||
return math.Floor(n*10) / 10
|
||||
}
|
||||
245
internal/spamassassin/spamc/spamc.go
Normal file
245
internal/spamassassin/spamc/spamc.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Package spamc provides a client for the SpamAssassin spamd protocol.
|
||||
// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL
|
||||
//
|
||||
// Modified to add timeouts from https://github.com/cgt/spamc
|
||||
package spamc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtoVersion is the protocol version
|
||||
const ProtoVersion = "1.5"
|
||||
|
||||
var (
|
||||
spamInfoRe = regexp.MustCompile(`(.+)\/(.+) (\d+) (.+)`)
|
||||
spamMainRe = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`)
|
||||
spamDetailsRe = regexp.MustCompile(`^\s?(-?[0-9\.]+)\s([a-zA-Z0-9_]*)(\W*)(.*)`)
|
||||
)
|
||||
|
||||
// connection is like net.Conn except that it also has a CloseWrite method.
|
||||
// CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some
|
||||
// reason it is not present in the net.Conn interface.
|
||||
type connection interface {
|
||||
net.Conn
|
||||
CloseWrite() error
|
||||
}
|
||||
|
||||
// Client is a spamd client.
|
||||
type Client struct {
|
||||
net string
|
||||
addr string
|
||||
timeout int
|
||||
}
|
||||
|
||||
// NewTCP returns a *Client that connects to spamd via the given TCP address.
|
||||
func NewTCP(addr string, timeout int) *Client {
|
||||
return &Client{"tcp", addr, timeout}
|
||||
}
|
||||
|
||||
// NewUnix returns a *Client that connects to spamd via the given Unix socket.
|
||||
func NewUnix(addr string) *Client {
|
||||
return &Client{"unix", addr, 0}
|
||||
}
|
||||
|
||||
// Rule represents a matched SpamAssassin rule.
|
||||
type Rule struct {
|
||||
Points string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Result struct
|
||||
type Result struct {
|
||||
ResponseCode int
|
||||
Message string
|
||||
Spam bool
|
||||
Score float64
|
||||
Threshold float64
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
// dial connects to spamd through TCP or a Unix socket.
|
||||
func (c *Client) dial() (connection, error) {
|
||||
if c.net == "tcp" {
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialTCP("tcp", nil, tcpAddr)
|
||||
} else if c.net == "unix" {
|
||||
unixAddr, err := net.ResolveUnixAddr("unix", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialUnix("unix", nil, unixAddr)
|
||||
}
|
||||
panic("Client.net must be either \"tcp\" or \"unix\"")
|
||||
}
|
||||
|
||||
// Report checks if message is spam or not, and returns score plus report
|
||||
func (c *Client) Report(email []byte) (Result, error) {
|
||||
output, err := c.report(email)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return c.parseOutput(output), nil
|
||||
}
|
||||
|
||||
func (c *Client) report(email []byte) ([]string, error) {
|
||||
conn, err := c.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(conn)
|
||||
_, err = bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.Write(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = bw.Flush()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Client is supposed to close its writing side of the connection
|
||||
// after sending its request.
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
lines []string
|
||||
br = bufio.NewReader(conn)
|
||||
)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, " \t\r\n")
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
// join lines, and replace multi-line descriptions with single line for each
|
||||
tmp := strings.Join(lines, "\n")
|
||||
re := regexp.MustCompile("\n ")
|
||||
n := re.ReplaceAllString(tmp, " ")
|
||||
|
||||
//split lines again
|
||||
return strings.Split(n, "\n"), nil
|
||||
}
|
||||
|
||||
func (c *Client) parseOutput(output []string) Result {
|
||||
var result Result
|
||||
var reachedRules bool
|
||||
for _, row := range output {
|
||||
// header
|
||||
if spamInfoRe.MatchString(row) {
|
||||
res := spamInfoRe.FindStringSubmatch(row)
|
||||
if len(res) == 5 {
|
||||
resCode, err := strconv.Atoi(res[3])
|
||||
if err == nil {
|
||||
result.ResponseCode = resCode
|
||||
}
|
||||
result.Message = res[4]
|
||||
continue
|
||||
}
|
||||
}
|
||||
// summary
|
||||
if spamMainRe.MatchString(row) {
|
||||
res := spamMainRe.FindStringSubmatch(row)
|
||||
if len(res) == 4 {
|
||||
if strings.ToLower(res[1]) == "true" || strings.ToLower(res[1]) == "yes" {
|
||||
result.Spam = true
|
||||
} else {
|
||||
result.Spam = false
|
||||
}
|
||||
resFloat, err := strconv.ParseFloat(res[2], 32)
|
||||
if err == nil {
|
||||
result.Score = resFloat
|
||||
continue
|
||||
}
|
||||
resFloat, err = strconv.ParseFloat(res[3], 32)
|
||||
if err == nil {
|
||||
result.Threshold = resFloat
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(row, "Content analysis details") {
|
||||
reachedRules = true
|
||||
continue
|
||||
}
|
||||
// details
|
||||
// row = strings.Trim(row, " \t\r\n")
|
||||
if reachedRules && spamDetailsRe.MatchString(row) {
|
||||
res := spamDetailsRe.FindStringSubmatch(row)
|
||||
if len(res) == 5 {
|
||||
rule := Rule{Points: res[1], Name: res[2], Description: res[4]}
|
||||
result.Rules = append(result.Rules, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Ping the spamd
|
||||
func (c *Client) Ping() error {
|
||||
conn, err := c.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
_, err = br.ReadSlice('\n')
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
139
internal/stats/stats.go
Normal file
139
internal/stats/stats.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package stats stores and returns Mailpit statistics
|
||||
package stats
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
)
|
||||
|
||||
var (
|
||||
// to prevent hammering Github for latest version
|
||||
latestVersionCache string
|
||||
|
||||
// StartedAt is set to the current ime when Mailpit starts
|
||||
startedAt time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
smtpAccepted int
|
||||
smtpAcceptedSize int
|
||||
smtpRejected int
|
||||
smtpIgnored int
|
||||
)
|
||||
|
||||
// AppInformation struct
|
||||
// swagger:model AppInformation
|
||||
type AppInformation struct {
|
||||
// Current Mailpit version
|
||||
Version string
|
||||
// Latest Mailpit version
|
||||
LatestVersion string
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize int64
|
||||
// Total number of messages in the database
|
||||
Messages int
|
||||
// Total number of messages in the database
|
||||
Unread int
|
||||
// Tags and message totals per tag
|
||||
Tags map[string]int64
|
||||
// Runtime statistics
|
||||
RuntimeStats struct {
|
||||
// Mailpit server uptime in seconds
|
||||
Uptime int
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
// Database runtime messages deleted
|
||||
MessagesDeleted int
|
||||
// Accepted runtime SMTP messages
|
||||
SMTPAccepted int
|
||||
// Total runtime accepted messages size in bytes
|
||||
SMTPAcceptedSize int
|
||||
// Rejected runtime SMTP messages
|
||||
SMTPRejected int
|
||||
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
|
||||
SMTPIgnored int
|
||||
}
|
||||
}
|
||||
|
||||
// Load the current statistics
|
||||
func Load() AppInformation {
|
||||
info := AppInformation{}
|
||||
info.Version = config.Version
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
|
||||
|
||||
info.RuntimeStats.Uptime = int(time.Since(startedAt).Seconds())
|
||||
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
|
||||
info.RuntimeStats.SMTPAccepted = smtpAccepted
|
||||
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
|
||||
info.RuntimeStats.SMTPRejected = smtpRejected
|
||||
info.RuntimeStats.SMTPIgnored = smtpIgnored
|
||||
|
||||
if latestVersionCache != "" {
|
||||
info.LatestVersion = latestVersionCache
|
||||
} else {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
latestVersionCache = latest
|
||||
|
||||
// clear latest version cache after 5 minutes
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
latestVersionCache = ""
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
info.Database = config.DataFile
|
||||
|
||||
db, err := os.Stat(info.Database)
|
||||
if err == nil {
|
||||
info.DatabaseSize = db.Size()
|
||||
}
|
||||
|
||||
info.Messages = storage.CountTotal()
|
||||
info.Unread = storage.CountUnread()
|
||||
|
||||
info.Tags = storage.GetAllTagsCount()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Track will start the statistics logging in memory
|
||||
func Track() {
|
||||
startedAt = time.Now()
|
||||
}
|
||||
|
||||
// LogSMTPAccepted logs a successful SMTP transaction
|
||||
func LogSMTPAccepted(size int) {
|
||||
mu.Lock()
|
||||
smtpAccepted = smtpAccepted + 1
|
||||
smtpAcceptedSize = smtpAcceptedSize + size
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// LogSMTPRejected logs a rejected SMTP transaction
|
||||
func LogSMTPRejected() {
|
||||
mu.Lock()
|
||||
smtpRejected = smtpRejected + 1
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// LogSMTPIgnored logs an ignored SMTP transaction
|
||||
func LogSMTPIgnored() {
|
||||
mu.Lock()
|
||||
smtpIgnored = smtpIgnored + 1
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -8,26 +8,25 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/GuiaBolso/darwin"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/leporo/sqlf"
|
||||
"github.com/mattn/go-shellwords"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
|
||||
// sqlite (native) - https://gitlab.com/cznic/sqlite
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -44,85 +43,8 @@ var (
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
dbDecoder, _ = zstd.NewReader(nil)
|
||||
|
||||
dbMigrations = []darwin.Migration{
|
||||
{
|
||||
Version: 1.0,
|
||||
Description: "Creating tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailbox (
|
||||
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
Data BLOB,
|
||||
Search TEXT,
|
||||
Read INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mailbox_data (
|
||||
ID TEXT KEY NOT NULL,
|
||||
Email BLOB
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
|
||||
},
|
||||
{
|
||||
Version: 1.1,
|
||||
Description: "Create tags column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.2,
|
||||
Description: "Creating new mailbox format",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
|
||||
Created INTEGER NOT NULL,
|
||||
ID TEXT NOT NULL,
|
||||
MessageID TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
Metadata TEXT,
|
||||
Size INTEGER NOT NULL,
|
||||
Inline INTEGER NOT NULL,
|
||||
Attachments INTEGER NOT NULL,
|
||||
Read INTEGER,
|
||||
Tags TEXT,
|
||||
SearchText TEXT
|
||||
);
|
||||
INSERT INTO mailboxtmp
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
|
||||
SELECT
|
||||
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
|
||||
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
|
||||
Search, Read, Tags
|
||||
FROM mailbox;
|
||||
|
||||
DROP TABLE IF EXISTS mailbox;
|
||||
ALTER TABLE mailboxtmp RENAME TO mailbox;
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
|
||||
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
|
||||
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
// Subject string
|
||||
// Size int
|
||||
// Inline int
|
||||
// Attachments int
|
||||
}
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
p := config.DataFile
|
||||
@@ -154,6 +76,12 @@ func InitDB() error {
|
||||
// @see https://github.com/mattn/go-sqlite3#faq
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
|
||||
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create tables if necessary & apply migrations
|
||||
if err := dbApplyMigrations(); err != nil {
|
||||
return err
|
||||
@@ -184,20 +112,11 @@ func InitDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create tables and apply migrations if required
|
||||
func dbApplyMigrations() error {
|
||||
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
|
||||
|
||||
d := darwin.New(driver, dbMigrations, nil)
|
||||
|
||||
return d.Migrate()
|
||||
}
|
||||
|
||||
// Close will close the database, and delete if a temporary table
|
||||
func Close() {
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Log().Warning("[db] error closing database, ignoring")
|
||||
logger.Log().Warn("[db] error closing database, ignoring")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,12 +128,13 @@ func Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Store will save an email to the database tables
|
||||
func Store(body []byte) (string, error) {
|
||||
// Parse message body with enmime.
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
|
||||
// Store will save an email to the database tables.
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
// Parse message body with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(*body))
|
||||
if err != nil {
|
||||
logger.Log().Warningf("[db] %s", err.Error())
|
||||
logger.Log().Warnf("[message] %s", err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -248,20 +168,24 @@ func Store(body []byte) (string, error) {
|
||||
searchText := createSearchText(env)
|
||||
|
||||
// generate unique ID
|
||||
id := uuid.NewV4().String()
|
||||
id := uuid.New().String()
|
||||
|
||||
summaryJSON, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tagData := findTags(&body)
|
||||
// extract tags from body matches based on --tag
|
||||
tagStr := findTagsInRawMessage(body)
|
||||
|
||||
tagJSON, err := json.Marshal(tagData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// extract tags from X-Tags header
|
||||
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
if headerTags != "" {
|
||||
tagStr += "," + headerTags
|
||||
}
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are stored successfully
|
||||
ctx := context.Background()
|
||||
@@ -274,19 +198,20 @@ func Store(body []byte) (string, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := len(body)
|
||||
size := len(*body)
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
// insert mail summary data
|
||||
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read) values(?,?,?,?,?,?,?,?,?,?,0)",
|
||||
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON))
|
||||
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) values(?,?,?,?,?,?,?,?,?,0,?)",
|
||||
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert compressed raw message
|
||||
compressed := dbEncoder.EncodeAll(body, make([]byte, 0, len(body)))
|
||||
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size))
|
||||
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -296,6 +221,13 @@ func Store(body []byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(tagData) > 0 {
|
||||
// set tags after tx.Commit()
|
||||
if err := SetMessageTags(id, tagData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
c := &MessageSummary{}
|
||||
if err := json.Unmarshal(summaryJSON, c); err != nil {
|
||||
return "", err
|
||||
@@ -303,15 +235,20 @@ func Store(body []byte) (string, error) {
|
||||
|
||||
c.Created = created
|
||||
c.ID = id
|
||||
c.MessageID = messageID
|
||||
c.Attachments = attachments
|
||||
c.Subject = subject
|
||||
c.Size = size
|
||||
c.Tags = tagData
|
||||
c.Snippet = snippet
|
||||
|
||||
websockets.Broadcast("new", c)
|
||||
webhook.Send(c)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -319,129 +256,62 @@ func Store(body []byte) (string, error) {
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created, ID, Subject, Metadata, Size, Attachments, Read, Tags`).
|
||||
OrderBy("Created DESC").
|
||||
q := sqlf.From("mailbox m").
|
||||
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
|
||||
OrderBy("m.Created DESC").
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
var snippet string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
|
||||
results = append(results, em)
|
||||
|
||||
// logger.PrettyPrint(em)
|
||||
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
// set tags for listed messages only
|
||||
for i, m := range results {
|
||||
results[i].Tags = getMessageTags(m.ID)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Search will search a mailbox for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search string, start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
s := strings.ToLower(search)
|
||||
// add another quote if missing closing quote
|
||||
quotes := strings.Count(s, `"`)
|
||||
if quotes%2 != 0 {
|
||||
s += `"`
|
||||
}
|
||||
|
||||
p := shellwords.NewParser()
|
||||
args, err := p.Parse(s)
|
||||
if err != nil {
|
||||
return results, errors.New("Your search contains invalid characters")
|
||||
}
|
||||
|
||||
// generate the SQL based on arguments
|
||||
q := searchParser(args, start, limit)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.ID = id
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
|
||||
results = append(results, em)
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
|
||||
logger.Log().Debugf("[db] list INBOX in %s", elapsed)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return results, err
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a Message generated from the mailbox_data collection.
|
||||
@@ -485,7 +355,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
var created int64
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -493,14 +363,13 @@ func GetMessage(id string) (*Message, error) {
|
||||
|
||||
date = time.UnixMilli(created)
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
obj := Message{
|
||||
ID: id,
|
||||
MessageID: messageID,
|
||||
Read: true,
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
@@ -514,10 +383,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
Text: env.Text,
|
||||
}
|
||||
|
||||
// strip base tags
|
||||
var re = regexp.MustCompile(`(?U)<base .*>`)
|
||||
html := re.ReplaceAllString(env.HTML, "")
|
||||
obj.HTML = html
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
|
||||
@@ -539,6 +405,20 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// get List-Unsubscribe links if set
|
||||
obj.ListUnsubscribe = ListUnsubscribe{}
|
||||
obj.ListUnsubscribe.Links = []string{}
|
||||
if env.GetHeader("List-Unsubscribe") != "" {
|
||||
l := env.GetHeader("List-Unsubscribe")
|
||||
links, err := tools.ListUnsubscribeParser(l)
|
||||
obj.ListUnsubscribe.Header = l
|
||||
obj.ListUnsubscribe.Links = links
|
||||
if err != nil {
|
||||
obj.ListUnsubscribe.Errors = err.Error()
|
||||
}
|
||||
obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post")
|
||||
}
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
return &obj, err
|
||||
@@ -614,6 +494,33 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
return nil, errors.New("attachment not found")
|
||||
}
|
||||
|
||||
// LatestID returns the latest message ID
|
||||
//
|
||||
// If a query argument is set in the request the function will return the
|
||||
// latest message matching the search
|
||||
func LatestID(r *http.Request) (string, error) {
|
||||
var messages []MessageSummary
|
||||
var err error
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = Search(search, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
messages, err = List(0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return "", errors.New("Message not found")
|
||||
}
|
||||
|
||||
return messages[0].ID, nil
|
||||
}
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(id string) error {
|
||||
if !IsUnread(id) {
|
||||
@@ -629,6 +536,8 @@ func MarkRead(id string) error {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -650,6 +559,8 @@ func MarkAllRead() error {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
@@ -673,6 +584,8 @@ func MarkAllUnread() error {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
@@ -695,6 +608,8 @@ func MarkUnread(id string) error {
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -726,9 +641,17 @@ func DeleteOneMessage(id string) error {
|
||||
logger.Log().Debugf("[db] deleted message %s", id)
|
||||
}
|
||||
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
logMessagesDeleted(1)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -763,6 +686,16 @@ func DeleteAllMessages() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM tags")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM message_tags")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -776,7 +709,10 @@ func DeleteAllMessages() error {
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = false
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -786,42 +722,11 @@ func StatsGet() MailboxStats {
|
||||
var (
|
||||
total = CountTotal()
|
||||
unread = CountUnread()
|
||||
tags = GetAllTags()
|
||||
)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`DISTINCT Tags`).
|
||||
Where("Tags != ?", "[]")
|
||||
|
||||
var tags = []string{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var tagData string
|
||||
t := []string{}
|
||||
|
||||
if err := row.Scan(&tagData); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagData), &t); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range t {
|
||||
if !inArray(tag, tags) {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return MailboxStats{
|
||||
Total: total,
|
||||
Unread: unread,
|
||||
@@ -881,7 +786,7 @@ func IsUnread(id string) bool {
|
||||
return unread == 1
|
||||
}
|
||||
|
||||
// MessageIDExists blaah
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
175
internal/storage/database_test.go
Normal file
175
internal/storage/database_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing text email storage")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
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 < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing mime email retrieval")
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing message summary")
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "Expected 1 result")
|
||||
|
||||
msg := summaries[0]
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
|
||||
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
|
||||
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
|
||||
}
|
||||
|
||||
func BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImportMime(b *testing.B) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
73
internal/storage/migrationTasks.go
Normal file
73
internal/storage/migrationTasks.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package storage
|
||||
|
||||
// These functions are used to migrate data formats/structure on startup.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
func dataMigrations() {
|
||||
migrateTagsToManyMany()
|
||||
}
|
||||
|
||||
// Migrate tags to ManyMany structure
|
||||
// Migration task implemented 12/2023
|
||||
// Can be removed end 06/2024 and Tags column & index dropped from mailbox
|
||||
func migrateTagsToManyMany() {
|
||||
toConvert := make(map[string][]string)
|
||||
q := sqlf.
|
||||
Select("ID, Tags").
|
||||
From("mailbox").
|
||||
Where("Tags != ?", "[]").
|
||||
Where("Tags IS NOT NULL")
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var jsonTags string
|
||||
if err := row.Scan(&id, &jsonTags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tags := []string{}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
toConvert[id] = tags
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
|
||||
if len(toConvert) > 0 {
|
||||
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
|
||||
for id, tags := range toConvert {
|
||||
if err := SetMessageTags(id, tags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
} else {
|
||||
if _, err := sqlf.Update("mailbox").
|
||||
Set("Tags", nil).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Info("[migration] tags conversion complete")
|
||||
}
|
||||
|
||||
// set all legacy `[]` tags to NULL
|
||||
if _, err := sqlf.Update("mailbox").
|
||||
Set("Tags", nil).
|
||||
Where("Tags = ?", "[]").
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
101
internal/storage/migrations.go
Normal file
101
internal/storage/migrations.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package storage
|
||||
|
||||
import "github.com/GuiaBolso/darwin"
|
||||
|
||||
var (
|
||||
dbMigrations = []darwin.Migration{
|
||||
{
|
||||
Version: 1.0,
|
||||
Description: "Creating tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailbox (
|
||||
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
Data BLOB,
|
||||
Search TEXT,
|
||||
Read INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mailbox_data (
|
||||
ID TEXT KEY NOT NULL,
|
||||
Email BLOB
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
|
||||
},
|
||||
{
|
||||
Version: 1.1,
|
||||
Description: "Create tags column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.2,
|
||||
Description: "Creating new mailbox format",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
|
||||
Created INTEGER NOT NULL,
|
||||
ID TEXT NOT NULL,
|
||||
MessageID TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
Metadata TEXT,
|
||||
Size INTEGER NOT NULL,
|
||||
Inline INTEGER NOT NULL,
|
||||
Attachments INTEGER NOT NULL,
|
||||
Read INTEGER,
|
||||
Tags TEXT,
|
||||
SearchText TEXT
|
||||
);
|
||||
INSERT INTO mailboxtmp
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
|
||||
SELECT
|
||||
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
|
||||
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
|
||||
Search, Read, Tags
|
||||
FROM mailbox;
|
||||
|
||||
DROP TABLE IF EXISTS mailbox;
|
||||
ALTER TABLE mailboxtmp RENAME TO mailbox;
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
|
||||
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
|
||||
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.3,
|
||||
Description: "Create snippet column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Snippet Text NOT NULL DEFAULT '';`,
|
||||
},
|
||||
{
|
||||
Version: 1.4,
|
||||
Description: "Create tag tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS tags (
|
||||
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
Name TEXT COLLATE NOCASE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tag_name ON tags (Name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_tags(
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT REFERENCES mailbox(ID),
|
||||
TagID INT REFERENCES tags(ID)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tag_id ON message_tags (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tag_tagid ON message_tags (TagID);`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Create tables and apply migrations if required
|
||||
func dbApplyMigrations() error {
|
||||
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
|
||||
|
||||
d := darwin.New(driver, dbMigrations, nil)
|
||||
|
||||
return d.Migrate()
|
||||
}
|
||||
38
internal/storage/notifications.go
Normal file
38
internal/storage/notifications.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
var bcStatsDelay = false
|
||||
|
||||
// BroadcastMailboxStats broadcasts the total number of messages
|
||||
// displayed to the web UI, as well as the total unread messages.
|
||||
// The lookup is very fast (< 10ms / 100k messages under load).
|
||||
// Rate limited to 4x per second.
|
||||
func BroadcastMailboxStats() {
|
||||
if bcStatsDelay {
|
||||
return
|
||||
}
|
||||
|
||||
bcStatsDelay = true
|
||||
|
||||
go func() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total int
|
||||
Unread int
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
Unread: CountUnread(),
|
||||
Version: config.Version,
|
||||
}
|
||||
|
||||
websockets.Broadcast("stats", b)
|
||||
}()
|
||||
}
|
||||
112
internal/storage/reindex.go
Normal file
112
internal/storage/reindex.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// ReindexAll will regenerate the search text and snippet for a message
|
||||
// and update the database.
|
||||
func ReindexAll() {
|
||||
ids := []string{}
|
||||
var i string
|
||||
chunkSize := 1000
|
||||
|
||||
finished := 0
|
||||
|
||||
err := sqlf.Select("ID").To(&i).
|
||||
From("mailbox").
|
||||
OrderBy("Created DESC").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
ids = append(ids, i)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
total := len(ids)
|
||||
|
||||
chunks := chunkBy(ids, chunkSize)
|
||||
|
||||
logger.Log().Infof("reindexing %d messages", total)
|
||||
|
||||
type updateStruct struct {
|
||||
ID string
|
||||
SearchText string
|
||||
Snippet string
|
||||
}
|
||||
|
||||
for _, ids := range chunks {
|
||||
updates := []updateStruct{}
|
||||
|
||||
for _, id := range ids {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
continue
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
searchText := createSearchText(env)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
u := updateStruct{}
|
||||
u.ID = id
|
||||
u.SearchText = searchText
|
||||
u.Snippet = snippet
|
||||
|
||||
updates = append(updates, u)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
// insert mail summary data
|
||||
for _, u := range updates {
|
||||
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ? WHERE ID = ?", u.SearchText, u.Snippet, u.ID)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
finished += len(updates)
|
||||
|
||||
logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
|
||||
}
|
||||
}
|
||||
|
||||
func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
|
||||
for chunkSize < len(items) {
|
||||
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
|
||||
}
|
||||
return append(chunks, items)
|
||||
}
|
||||
338
internal/storage/search.go
Normal file
338
internal/storage/search.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// Search will search a mailbox for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
results := []MessageSummary{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
nrResults := 0
|
||||
if limit < 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search)
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
|
||||
allResults = append(allResults, em)
|
||||
}); err != nil {
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
nrResults = len(allResults)
|
||||
|
||||
if nrResults > start {
|
||||
end := nrResults
|
||||
if nrResults >= start+limit {
|
||||
end = start + limit
|
||||
}
|
||||
|
||||
results = allResults[start:end]
|
||||
}
|
||||
|
||||
// set tags for listed messages only
|
||||
for i, m := range results {
|
||||
results[i].Tags = getMessageTags(m.ID)
|
||||
}
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
|
||||
|
||||
return results, nrResults, err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func DeleteSearch(search string) error {
|
||||
q := searchQueryBuilder(search)
|
||||
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
// var tags string
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
total := len(ids)
|
||||
|
||||
// split ids into chunks of 1000 ids
|
||||
var chunks [][]string
|
||||
if total > 1000 {
|
||||
chunkSize := 1000
|
||||
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
|
||||
for chunkSize < len(ids) {
|
||||
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
// add remaining ids <= 1000
|
||||
chunks = append(chunks, ids)
|
||||
}
|
||||
} else {
|
||||
chunks = append(chunks, ids)
|
||||
}
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, ids := range chunks {
|
||||
delIDs := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
delIDs[i] = id
|
||||
}
|
||||
|
||||
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
_, err = tx.Exec(sqlDelete1, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
_, err = tx.Exec(sqlDelete2, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDelete3 := `DELETE FROM message_tags WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
|
||||
_, err = tx.Exec(sqlDelete3, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
args := tools.ArgsParser(searchString)
|
||||
|
||||
q := sqlf.From("mailbox m").
|
||||
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,
|
||||
m.Snippet,
|
||||
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
|
||||
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
|
||||
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
|
||||
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON
|
||||
`).
|
||||
OrderBy("m.Created DESC")
|
||||
|
||||
for _, w := range args {
|
||||
if cleanString(w) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// lowercase search to try match search prefixes
|
||||
lw := strings.ToLower(w)
|
||||
|
||||
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:]
|
||||
lw = lw[1:]
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||
if !re.MatchString(w) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lw, "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(lw, "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(lw, "cc:") {
|
||||
w = cleanString(w[3:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "bcc:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "subject:") {
|
||||
w = w[8:]
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "message-id:") {
|
||||
w = cleanString(w[11:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "tag:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where(`m.ID NOT IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
}
|
||||
}
|
||||
} else if lw == "is:read" {
|
||||
if exclude {
|
||||
q.Where("Read = 0")
|
||||
} else {
|
||||
q.Where("Read = 1")
|
||||
}
|
||||
} else if lw == "is:unread" {
|
||||
if exclude {
|
||||
q.Where("Read = 1")
|
||||
} else {
|
||||
q.Where("Read = 0")
|
||||
}
|
||||
} else if lw == "is:tagged" {
|
||||
if exclude {
|
||||
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
}
|
||||
} else if lw == "has:attachment" || lw == "has:attachments" {
|
||||
if exclude {
|
||||
q.Where("Attachments = 0")
|
||||
} else {
|
||||
q.Where("Attachments > 0")
|
||||
}
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
|
||||
} else {
|
||||
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
170
internal/storage/search_test.go
Normal file
170
internal/storage/search_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
searchIdx := rand.Intn(4) + 1
|
||||
var search string
|
||||
switch searchIdx {
|
||||
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 results
|
||||
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 TestSearchDelete100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete1100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 1100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
|
||||
func TestEscPercentChar(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
tests["this is a test"] = "this is a test"
|
||||
tests["this is% a test"] = "this is%% a test"
|
||||
tests["this is%% a test"] = "this is%%%% a test"
|
||||
tests["this is%%% a test"] = "this is%%%%%% a test"
|
||||
tests["%this is% a test"] = "%%this is%% a test"
|
||||
tests["Ä"] = "Ä"
|
||||
tests["Ä%"] = "Ä%%"
|
||||
|
||||
for search, expected := range tests {
|
||||
res := escPercentChar(search)
|
||||
assertEqual(t, res, expected, "no match")
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ type Message struct {
|
||||
ID string
|
||||
// Message ID
|
||||
MessageID string
|
||||
// Read status
|
||||
Read bool
|
||||
// From address
|
||||
From *mail.Address
|
||||
// To addresses
|
||||
@@ -31,6 +29,9 @@ type Message struct {
|
||||
ReturnPath string
|
||||
// Message subject
|
||||
Subject string
|
||||
// List-Unsubscribe header information
|
||||
// swagger:ignore
|
||||
ListUnsubscribe ListUnsubscribe
|
||||
// Message date if set, else date received
|
||||
Date time.Time
|
||||
// Message tags
|
||||
@@ -69,6 +70,8 @@ type Attachment struct {
|
||||
type MessageSummary struct {
|
||||
// Database ID
|
||||
ID string
|
||||
// Message ID
|
||||
MessageID string
|
||||
// Read status
|
||||
Read bool
|
||||
// From address
|
||||
@@ -89,6 +92,8 @@ type MessageSummary struct {
|
||||
Size int
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
// Message snippet includes up to 250 characters
|
||||
Snippet string
|
||||
}
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
@@ -98,6 +103,14 @@ type MailboxStats struct {
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
@@ -112,3 +125,16 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
// including validation of the link structure
|
||||
type ListUnsubscribe struct {
|
||||
// List-Unsubscribe header value
|
||||
Header string
|
||||
// Detected links, maximum one email and one HTTP(S)
|
||||
Links []string
|
||||
// Validation errors if any
|
||||
Errors string
|
||||
// List-Unsubscribe-Post value if set
|
||||
HeaderPost string
|
||||
}
|
||||
287
internal/storage/tags.go
Normal file
287
internal/storage/tags.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetMessageTags will set the tags for a given database ID
|
||||
func SetMessageTags(id string, tags []string) error {
|
||||
applyTags := []string{}
|
||||
for _, t := range tags {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
|
||||
currentTags := getMessageTags(id)
|
||||
origTagCount := len(currentTags)
|
||||
|
||||
for _, t := range applyTags {
|
||||
t = tools.CleanTag(t)
|
||||
if t == "" || !config.ValidTagRegexp.MatchString(t) || inArray(t, currentTags) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := AddMessageTag(id, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if origTagCount > 0 {
|
||||
currentTags = getMessageTags(id)
|
||||
|
||||
for _, t := range currentTags {
|
||||
if !inArray(t, applyTags) {
|
||||
if err := DeleteMessageTag(id, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMessageTag adds a tag to a message
|
||||
func AddMessageTag(id, name string) error {
|
||||
var tagID int
|
||||
|
||||
q := sqlf.From("tags").
|
||||
Select("ID").To(&tagID).
|
||||
Where("Name = ?", name)
|
||||
|
||||
// tag exists - add tag to message
|
||||
if err := q.QueryRowAndClose(nil, db); err == nil {
|
||||
// check message does not already have this tag
|
||||
var count int
|
||||
if _, err := sqlf.From("message_tags").
|
||||
Select("COUNT(ID)").To(&count).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
// already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
_, err := sqlf.InsertInto("message_tags").
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(nil, db)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
// tag dos not exist, add new one
|
||||
if err := sqlf.InsertInto("tags").
|
||||
Set("Name", name).
|
||||
Returning("ID").To(&tagID).
|
||||
QueryRowAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check message does not already have this tag
|
||||
var count int
|
||||
if _, err := sqlf.From("message_tags").
|
||||
Select("COUNT(ID)").To(&count).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
// add tag to message
|
||||
_, err := sqlf.InsertInto("message_tags").
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(nil, db)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMessageTag deleted a tag from a message
|
||||
func DeleteMessageTag(id, name string) error {
|
||||
if _, err := sqlf.DeleteFrom("message_tags").
|
||||
Where("message_tags.ID = ?", id).
|
||||
Where(`message_tags.Key IN (SELECT Key FROM message_tags LEFT JOIN tags ON TagID=tags.ID WHERE Name = ?)`, name).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func DeleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom("message_tags").
|
||||
Where("message_tags.ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// GetAllTags returns all used tags
|
||||
func GetAllTags() []string {
|
||||
var tags = []string{}
|
||||
var name string
|
||||
|
||||
if err := sqlf.
|
||||
Select(`DISTINCT Name`).
|
||||
From("tags").To(&name).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// GetAllTagsCount returns all used tags with their total messages
|
||||
func GetAllTagsCount() map[string]int64 {
|
||||
var tags = make(map[string]int64)
|
||||
var name string
|
||||
var total int64
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
Select(`COUNT(message_tags.TagID) as total`).To(&total).
|
||||
From("tags").
|
||||
LeftJoin("message_tags", "tags.ID = message_tags.TagID").
|
||||
GroupBy("message_tags.TagID").
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
// tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// PruneUnusedTags will delete all unused tags from the database
|
||||
func pruneUnusedTags() error {
|
||||
q := sqlf.From("tags").
|
||||
Select("tags.ID, tags.Name, COUNT(message_tags.ID) as COUNT").
|
||||
LeftJoin("message_tags", "tags.ID = message_tags.TagID").
|
||||
GroupBy("tags.ID")
|
||||
|
||||
toDel := []int{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var n string
|
||||
var id int
|
||||
var c int
|
||||
|
||||
if err := row.Scan(&id, &n, &c); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if c == 0 {
|
||||
logger.Log().Debugf("[tags] deleting unused tag \"%s\"", n)
|
||||
toDel = append(toDel, id)
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(toDel) > 0 {
|
||||
for _, id := range toDel {
|
||||
if _, err := sqlf.DeleteFrom("tags").
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find tags set via --tags in raw message.
|
||||
// Returns a comma-separated string.
|
||||
func findTagsInRawMessage(message *[]byte) string {
|
||||
tagStr := ""
|
||||
if len(config.SMTPTags) == 0 {
|
||||
return tagStr
|
||||
}
|
||||
|
||||
str := strings.ToLower(string(*message))
|
||||
for _, t := range config.SMTPTags {
|
||||
if strings.Contains(str, t.Match) {
|
||||
tagStr += "," + t.Tag
|
||||
}
|
||||
}
|
||||
|
||||
return tagStr
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given database ID
|
||||
// Used when parsing a raw email.
|
||||
func getMessageTags(id string) []string {
|
||||
tags := []string{}
|
||||
var name string
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
From("Tags").
|
||||
LeftJoin("message_tags", "Tags.ID=message_tags.TagID").
|
||||
Where(`message_tags.ID = ?`, id).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
return tags
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
|
||||
func uniqueTagsFromString(s string) []string {
|
||||
tags := []string{}
|
||||
|
||||
if s == "" {
|
||||
return tags
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
w := tools.CleanTag(p)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if config.ValidTagRegexp.MatchString(w) {
|
||||
if !inArray(w, tags) {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
111
internal/storage/tags_test.go
Normal file
111
internal/storage/tags_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing tags")
|
||||
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
}
|
||||
}
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
// pad number with 0 to ensure they are returned alphabetically
|
||||
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
|
||||
}
|
||||
if err := SetMessageTags(id, newTags); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags := getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
// remove first tag
|
||||
if err := DeleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
|
||||
|
||||
// apply the same tag twice
|
||||
if err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// apply tag with invalid characters
|
||||
if err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Dirty message tag did not clean as expected")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Delivered-To: recipient@example.com
|
||||
Delivered-To: recipient2@example.com
|
||||
Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs;
|
||||
Tue, 26 Jul 2022 20:42:36 -0700 (PDT)
|
||||
X-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881;
|
||||
@@ -23,18 +23,18 @@ ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc
|
||||
uSfA==
|
||||
ARC-Authentication-Results: i=1; mx.google.com;
|
||||
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
|
||||
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
|
||||
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
|
||||
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
|
||||
Return-Path: <sender@example.com>
|
||||
Return-Path: <sender2@example.com>
|
||||
Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])
|
||||
by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35
|
||||
for <recipient@example.com>
|
||||
for <recipient2@example.com>
|
||||
(Google Transport Security);
|
||||
Tue, 26 Jul 2022 20:42:35 -0700 (PDT)
|
||||
Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
|
||||
Received-SPF: pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
|
||||
Authentication-Results: mx.google.com;
|
||||
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
|
||||
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
|
||||
spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com;
|
||||
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=gmail.com; s=20210112;
|
||||
@@ -63,10 +63,10 @@ X-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77
|
||||
X-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ==
|
||||
X-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675;
|
||||
Tue, 26 Jul 2022 20:42:34 -0700 (PDT)
|
||||
Return-Path: <sender@example.com>
|
||||
Return-Path: <sender2@example.com>
|
||||
Received: from [192.168.1.2] ([8.8.8.8])
|
||||
by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32
|
||||
for <recipient@example.com>
|
||||
for <recipient2@example.com>
|
||||
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
|
||||
Tue, 26 Jul 2022 20:42:33 -0700 (PDT)
|
||||
Content-Type: multipart/mixed; boundary="------------ae0qIOkrNQLQHe1YyfTsUXrk"
|
||||
@@ -76,8 +76,8 @@ MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
|
||||
Thunderbird/91.11.0
|
||||
Content-Language: en-NZ
|
||||
To: "Recipient Ross" <recipient@example.com>
|
||||
From: Sender Smith <sender@example.com>
|
||||
To: "Recipient Ross" <recipient2@example.com>
|
||||
From: Sender Smith <sender2@example.com>
|
||||
Subject: inline + attachment
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
@@ -108,10 +108,9 @@ Content-Transfer-Encoding: 7bit
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
Message with inline image and attachment:<br>
|
||||
<h1>Message with inline image and attachment:</h1>
|
||||
<br>
|
||||
<img src="cid:part1.845LaYlX.wtWMpWwa@gmail.com"
|
||||
moz-do-not-send="false"><br>
|
||||
<p><img src="cid:part1.845LaYlX.wtWMpWwa@gmail.com"></p>
|
||||
<br>
|
||||
<br>
|
||||
</body>
|
||||
57
internal/storage/testing.go
Normal file
57
internal/storage/testing.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
testTextEmail []byte
|
||||
testMimeEmail []byte
|
||||
testRuns = 100
|
||||
)
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if total != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,24 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/k3a/html2text"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
var (
|
||||
// for stats to prevent import cycle
|
||||
mu sync.RWMutex
|
||||
// StatsDeleted for counting the number of messages deleted
|
||||
StatsDeleted int
|
||||
)
|
||||
|
||||
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
||||
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
|
||||
data, err := env.AddressList(key)
|
||||
@@ -37,12 +45,10 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
b.WriteString(env.GetHeader("To") + " ")
|
||||
b.WriteString(env.GetHeader("Cc") + " ")
|
||||
b.WriteString(env.GetHeader("Bcc") + " ")
|
||||
h := strings.TrimSpace(
|
||||
html2text.HTML2TextWithOptions(
|
||||
env.HTML,
|
||||
html2text.WithLinksInnerText(),
|
||||
),
|
||||
)
|
||||
b.WriteString(env.GetHeader("Reply-To") + " ")
|
||||
b.WriteString(env.GetHeader("Return-Path") + " ")
|
||||
|
||||
h := html2text.Strip(env.HTML, true)
|
||||
if h != "" {
|
||||
b.WriteString(h + " ")
|
||||
} else {
|
||||
@@ -60,8 +66,11 @@ func createSearchText(env *enmime.Envelope) string {
|
||||
|
||||
// CleanString removes unwanted characters from stored search text and search queries
|
||||
func cleanString(str string) string {
|
||||
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
|
||||
str = strings.ReplaceAll(str, string('\uFEFF'), " ")
|
||||
|
||||
// remove/replace new lines
|
||||
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;)`)
|
||||
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
|
||||
str = re.ReplaceAllString(str, " ")
|
||||
|
||||
// remove duplicate whitespace and trim
|
||||
@@ -75,7 +84,7 @@ func dbCron() {
|
||||
time.Sleep(60 * time.Second)
|
||||
start := time.Now()
|
||||
|
||||
// check if database contains deleted data and has not beein in use
|
||||
// check if database contains deleted data and has not been in use
|
||||
// for 5 minutes, if so VACUUM
|
||||
currentTime := time.Now()
|
||||
diff := currentTime.Sub(dbLastAction)
|
||||
@@ -138,25 +147,44 @@ func dbCron() {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM message_tags 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())
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
if err := tx.Rollback(); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
dbDataDeleted = true
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
|
||||
|
||||
logMessagesDeleted(len(ids))
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogMessagesDeleted logs the number of messages deleted
|
||||
func logMessagesDeleted(n int) {
|
||||
mu.Lock()
|
||||
StatsDeleted = StatsDeleted + n
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// IsFile returns whether a path is a file
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
@@ -167,6 +195,7 @@ func isFile(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
@@ -182,3 +211,42 @@ func inArray(k string, arr []string) bool {
|
||||
func escPercentChar(s string) string {
|
||||
return strings.ReplaceAll(s, "%", "%%")
|
||||
}
|
||||
|
||||
// Escape certain characters in search phrases
|
||||
func escSearch(str string) string {
|
||||
dest := make([]byte, 0, 2*len(str))
|
||||
var escape byte
|
||||
for i := 0; i < len(str); i++ {
|
||||
c := str[i]
|
||||
|
||||
escape = 0
|
||||
|
||||
switch c {
|
||||
case 0: /* Must be escaped for 'mysql' */
|
||||
escape = '0'
|
||||
break
|
||||
case '\n': /* Must be escaped for logs */
|
||||
escape = 'n'
|
||||
break
|
||||
case '\r':
|
||||
escape = 'r'
|
||||
break
|
||||
case '\\':
|
||||
escape = '\\'
|
||||
break
|
||||
case '\'':
|
||||
escape = '\''
|
||||
break
|
||||
case '\032': //十进制26,八进制32,十六进制1a, /* This gives problems on Win32 */
|
||||
escape = 'Z'
|
||||
}
|
||||
|
||||
if escape != 0 {
|
||||
dest = append(dest, '\\', escape)
|
||||
} else {
|
||||
dest = append(dest, c)
|
||||
}
|
||||
}
|
||||
|
||||
return string(dest)
|
||||
}
|
||||
32
internal/tools/argsparser.go
Normal file
32
internal/tools/argsparser.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package tools
|
||||
|
||||
import "strings"
|
||||
|
||||
// ArgsParser will split a string by new words and quotes phrases
|
||||
func ArgsParser(s string) []string {
|
||||
args := []string{}
|
||||
sb := &strings.Builder{}
|
||||
quoted := false
|
||||
for _, r := range s {
|
||||
if r == '"' {
|
||||
quoted = !quoted
|
||||
sb.WriteRune(r) // keep '"' otherwise comment this line
|
||||
} else if !quoted && r == ' ' {
|
||||
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
|
||||
if v != "" {
|
||||
args = append(args, v)
|
||||
}
|
||||
sb.Reset()
|
||||
} else {
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
|
||||
if v != "" {
|
||||
args = append(args, v)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
19
internal/tools/html.go
Normal file
19
internal/tools/html.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// GetHTMLAttributeVal returns the value of an HTML Attribute, else an error.
|
||||
// Returns a blank value if the attribute is set but empty.
|
||||
func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
for _, a := range e.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%s not found", key)
|
||||
}
|
||||
99
internal/tools/listunsubscribeparser.go
Normal file
99
internal/tools/listunsubscribeparser.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return
|
||||
// a slide of addresses (mail & URLs)
|
||||
func ListUnsubscribeParser(v string) ([]string, error) {
|
||||
var results = []string{}
|
||||
var re = regexp.MustCompile(`(?mU)<(.*)>`)
|
||||
var reJoins = regexp.MustCompile(`(?imUs)>(.*)<`)
|
||||
var reValidJoinChars = regexp.MustCompile(`(?imUs)^(\s+)?,(\s+)?$`)
|
||||
var reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`)
|
||||
var reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`)
|
||||
var reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`)
|
||||
var reSpaces = regexp.MustCompile(`\s`)
|
||||
var reComments = regexp.MustCompile(`(?mUs)\(.*\)`)
|
||||
var hasMailTo bool
|
||||
var hasHTTP bool
|
||||
|
||||
v = strings.TrimSpace(v)
|
||||
|
||||
comments := reComments.FindAllStringSubmatch(v, -1)
|
||||
for _, c := range comments {
|
||||
// strip comments
|
||||
v = strings.Replace(v, c[0], "", -1)
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
if !re.MatchString(v) {
|
||||
return results, fmt.Errorf("\"%s\" no valid unsubscribe links found", v)
|
||||
}
|
||||
|
||||
errors := []string{}
|
||||
|
||||
if !reWrapper.MatchString(v) {
|
||||
return results, fmt.Errorf("\"%s\" should be enclosed in <>", v)
|
||||
}
|
||||
|
||||
matches := re.FindAllStringSubmatch(v, -1)
|
||||
|
||||
if len(matches) > 2 {
|
||||
errors = append(errors, fmt.Sprintf("\"%s\" should include a maximum of one email and one HTTP link", v))
|
||||
} else {
|
||||
splits := reJoins.FindAllStringSubmatch(v, -1)
|
||||
for _, g := range splits {
|
||||
if !reValidJoinChars.MatchString(g[1]) {
|
||||
return results, fmt.Errorf("\"%s\" <> should be split with a comma and optional spaces", v)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
r := m[1]
|
||||
if reSpaces.MatchString(r) {
|
||||
errors = append(errors, fmt.Sprintf("\"%s\" should not contain spaces", r))
|
||||
continue
|
||||
}
|
||||
|
||||
if reMailTo.MatchString(r) {
|
||||
if hasMailTo {
|
||||
errors = append(errors, fmt.Sprintf("\"%s\" should only contain one mailto:", r))
|
||||
continue
|
||||
}
|
||||
|
||||
hasMailTo = true
|
||||
} else if reHTTP.MatchString(r) {
|
||||
if hasHTTP {
|
||||
errors = append(errors, fmt.Sprintf("\"%s\" should only contain one HTTP link", r))
|
||||
continue
|
||||
}
|
||||
|
||||
hasHTTP = true
|
||||
|
||||
} else {
|
||||
errors = append(errors, fmt.Sprintf("\"%s\" should start with either http(s):// or mailto:", r))
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := url.ParseRequestURI(r)
|
||||
if err != nil {
|
||||
errors = append(errors, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if len(errors) > 0 {
|
||||
err = fmt.Errorf("%s", strings.Join(errors, ", "))
|
||||
}
|
||||
|
||||
return results, err
|
||||
}
|
||||
99
internal/tools/message.go
Normal file
99
internal/tools/message.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Package tools provides various methods for various things
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
// RemoveMessageHeaders scans a message for headers, if found them removes them.
|
||||
// It will only remove a single instance of any given message header.
|
||||
func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
|
||||
for _, hdr := range headers {
|
||||
// case-insensitive
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
|
||||
|
||||
// header := []byte(hdr + ":")
|
||||
if m.Header.Get(hdr) != "" {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] removed %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(""), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// UpdateMessageHeader scans a message for a header and updates its value if found.
|
||||
func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.Header.Get(header) != "" {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(header+":"))
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] replaced %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
44
internal/tools/snippets.go
Normal file
44
internal/tools/snippets.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
)
|
||||
|
||||
// CreateSnippet returns a message snippet. It will use the HTML version (if it exists)
|
||||
// otherwise the text version.
|
||||
func CreateSnippet(text, html string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
html = strings.TrimSpace(html)
|
||||
limit := 200
|
||||
spaceRe := regexp.MustCompile(`\s+`)
|
||||
|
||||
if text == "" && html == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if html != "" {
|
||||
data := html2text.Strip(html, false)
|
||||
|
||||
if len(data) <= limit {
|
||||
return data
|
||||
}
|
||||
|
||||
return data[0:limit] + "..."
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
|
||||
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
|
||||
text = strings.TrimSpace(spaceRe.ReplaceAllString(text, " "))
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
|
||||
return text[0:limit] + "..."
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
25
internal/tools/tags.go
Normal file
25
internal/tools/tags.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, removing whitespace and invalid characters
|
||||
func CleanTag(s string) string {
|
||||
s = strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
return s
|
||||
}
|
||||
119
internal/tools/tools_test.go
Normal file
119
internal/tools/tools_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArgsParser(t *testing.T) {
|
||||
tests := map[string][]string{}
|
||||
tests["this is a test"] = []string{"this", "is", "a", "test"}
|
||||
tests["\"this is\" a test"] = []string{"this is", "a", "test"}
|
||||
tests["!\"this is\" a test"] = []string{"!this is", "a", "test"}
|
||||
tests["subject:this is a test"] = []string{"subject:this", "is", "a", "test"}
|
||||
tests["subject:\"this is\" a test"] = []string{"subject:this is", "a", "test"}
|
||||
tests["subject:\"this is\" \"a test\""] = []string{"subject:this is", "a test"}
|
||||
tests["subject:\"this 'is\" \"a test\""] = []string{"subject:this 'is", "a test"}
|
||||
tests["subject:\"this 'is a test"] = []string{"subject:this 'is a test"}
|
||||
tests["\"this is a test\"=\"this is a test\""] = []string{"this is a test=this is a test"}
|
||||
|
||||
for search, expected := range tests {
|
||||
res := ArgsParser(search)
|
||||
if !reflect.DeepEqual(res, expected) {
|
||||
t.Log("Args parser error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTag(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
tests["this is a test"] = "this is a test"
|
||||
tests["thiS IS a Test"] = "thiS IS a Test"
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test -"
|
||||
tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -"
|
||||
tests["this_is-a test "] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
|
||||
|
||||
for search, expected := range tests {
|
||||
res := CleanTag(search)
|
||||
if res != expected {
|
||||
t.Log("CleanTags error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippets(t *testing.T) {
|
||||
tests := map[string]string{}
|
||||
tests["this is a test"] = "this is a test"
|
||||
tests["thiS IS a Test"] = "thiS IS a Test"
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
|
||||
tests["<h1>This is a test.</h1> "] = "This is a test."
|
||||
tests["this_is-a test "] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a&^%%(*)@ test"
|
||||
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1>
|
||||
<p>Paragraph</p>`] = "Heading Paragraph"
|
||||
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text"
|
||||
// broken html
|
||||
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
|
||||
// truncation to 200 chars + ...
|
||||
tests["abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789"] = "abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmno..."
|
||||
|
||||
for str, expected := range tests {
|
||||
res := CreateSnippet(str, str)
|
||||
if res != expected {
|
||||
t.Log("CreateSnippet error:", res, "!=", expected)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUnsubscribeParser(t *testing.T) {
|
||||
tests := map[string]bool{}
|
||||
|
||||
// should pass
|
||||
tests["<mailto:unsubscribe@example.com>"] = true
|
||||
tests["<https://example.com>"] = true
|
||||
tests["<HTTPS://EXAMPLE.COM>"] = true
|
||||
tests["<mailto:unsubscribe@example.com>, <http://example.com>"] = true
|
||||
tests["<mailto:unsubscribe@example.com>, <https://example.com>"] = true
|
||||
tests["<https://example.com>, <mailto:unsubscribe@example.com>"] = true
|
||||
tests["<https://example.com> , <mailto:unsubscribe@example.com>"] = true
|
||||
tests["<https://example.com> ,<mailto:unsubscribe@example.com>"] = true
|
||||
tests["<mailto:unsubscribe@example.com>,<https://example.com>"] = true
|
||||
tests[`<https://example.com> ,
|
||||
<mailto:unsubscribe@example.com>`] = true
|
||||
tests["<mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
|
||||
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
|
||||
tests["<mailto:unsubscribe@example.com> (Use this command to get off the list)"] = true
|
||||
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com>, (Click this link to unsubscribe) <http://example.com>"] = true
|
||||
|
||||
// should fail
|
||||
tests["mailto:unsubscribe@example.com"] = false // no <>
|
||||
tests["<mailto::unsubscribe@example.com>"] = false // ::
|
||||
tests["https://example.com/"] = false // no <>
|
||||
tests["mailto:unsubscribe@example.com, <https://example.com/>"] = false // no <>
|
||||
tests["<MAILTO:unsubscribe@example.com>"] = false // capitals
|
||||
tests["<mailto:unsubscribe@example.com>, <mailto:test2@example.com>"] = false // two emails
|
||||
tests["<http://exampl\\e2.com>, <http://example2.com>"] = false // two links
|
||||
tests["<http://example.com>, <mailto:unsubscribe@example.com>, <http://example2.com>"] = false // two links
|
||||
tests["<mailto:unsubscribe@example.com>, <example.com>"] = false // no mailto || http(s)
|
||||
tests["<mailto: unsubscribe@example.com>, <unsubscribe@lol.com>"] = false // space
|
||||
tests["<mailto:unsubscribe@example.com?subject=unsubscribe me>"] = false // space
|
||||
tests["<http:///example.com>"] = false // http:///
|
||||
|
||||
for search, expected := range tests {
|
||||
_, err := ListUnsubscribeParser(search)
|
||||
hasError := err != nil
|
||||
if expected == hasError {
|
||||
if err != nil {
|
||||
t.Logf("ListUnsubscribeParser: %v", err)
|
||||
} else {
|
||||
t.Logf("ListUnsubscribeParser: \"%s\" expected: %v", search, expected)
|
||||
}
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// package Updater checks and downloads new versions
|
||||
package updater
|
||||
|
||||
import (
|
||||
@@ -6,14 +7,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
)
|
||||
|
||||
@@ -49,13 +51,27 @@ type Release struct {
|
||||
func GithubLatest(repo, name string) (string, string, string, error) {
|
||||
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
|
||||
|
||||
resp, err := http.Get(releaseURL) // #nosec
|
||||
timeout := time.Duration(5 * time.Second)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", releaseURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
@@ -205,7 +221,7 @@ func downloadToFile(url, fileName string) error {
|
||||
|
||||
defer func() {
|
||||
if err := out.Close(); err != nil {
|
||||
logger.Log().Errorf("Error closing file: %s\n", err)
|
||||
logger.Log().Errorf("error closing file: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -289,11 +305,7 @@ func replaceFile(dst, src string) error {
|
||||
}
|
||||
|
||||
// remove the src file
|
||||
if err := os.Remove(src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
// GetTempDir will create & return a temporary directory if one has not been specified
|
||||
@@ -307,7 +319,7 @@ func getTempDir() string {
|
||||
}
|
||||
if err := mkDirIfNotExists(tempDir); err != nil {
|
||||
// need a better way to exit
|
||||
logger.Log().Errorf("Error: %v", err)
|
||||
logger.Log().Errorf("error: %s", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
2357
package-lock.json
generated
2357
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -3,26 +3,32 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"build": "MINIFY=true node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs"
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.4.41",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"moment": "^2.29.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13"
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.17.5",
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^2.3.2"
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,45 @@ package cmd
|
||||
|
||||
/**
|
||||
* Bare bones sendmail drop-in replacement borrowed from MailHog
|
||||
*
|
||||
* It uses a bit of a hack for flag parsing in order to be compatible
|
||||
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
|
||||
* is not POSIX compatible.
|
||||
*
|
||||
* The -bs command-line switch causes sendmail to run a single SMTP session in the
|
||||
* foreground over its standard input and output, and then exit. The SMTP session
|
||||
* is exactly like a network SMTP session. Usually, one or more messages are
|
||||
* submitted to sendmail for delivery.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/reiver/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
// Verbose flag
|
||||
Verbose bool
|
||||
// SMTPAddr address
|
||||
SMTPAddr = "localhost:1025"
|
||||
// FromAddr email address
|
||||
FromAddr string
|
||||
|
||||
fromAddr string
|
||||
// UseB - used to set from `-bs`
|
||||
UseB bool
|
||||
// UseS - used to set from `-bs`
|
||||
UseS bool
|
||||
)
|
||||
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
func init() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
host = "localhost"
|
||||
@@ -39,50 +53,76 @@ func Run() {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
if fromAddr == "" {
|
||||
fromAddr = username + "@" + host
|
||||
if FromAddr == "" {
|
||||
FromAddr = username + "@" + host
|
||||
}
|
||||
}
|
||||
|
||||
smtpAddr := "localhost:1025"
|
||||
var recip []string
|
||||
// Run the Mailpit sendmail replacement.
|
||||
func Run() {
|
||||
var recipients []string
|
||||
|
||||
// defaults from envars if provided
|
||||
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
|
||||
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
|
||||
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
|
||||
}
|
||||
|
||||
// override defaults from cli flags
|
||||
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender address")
|
||||
flag.StringVarP(&smtpAddr, "smtp-addr", "S", smtpAddr, "SMTP server address")
|
||||
flag.BoolVarP(&Verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
flag.BoolP("long-b", "b", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-o", "o", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-s", "s", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
|
||||
flag.CommandLine.SortFlags = false
|
||||
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
|
||||
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
|
||||
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
flag.BoolP("verbose", "v", false, "Ignored")
|
||||
flag.BoolP("long-i", "i", false, "Ignored")
|
||||
flag.BoolP("long-o", "o", false, "Ignored")
|
||||
flag.BoolP("long-t", "t", false, "Ignored")
|
||||
|
||||
// set the default help
|
||||
flag.Usage = func() {
|
||||
fmt.Printf("A sendmail command replacement for Mailpit (%s).\n\n", config.Version)
|
||||
fmt.Printf("Usage:\n %s [flags] [recipients]\n", os.Args[0])
|
||||
fmt.Println("\nFlags:")
|
||||
flag.PrintDefaults()
|
||||
fmt.Println(HelpTemplate(os.Args[0:1]))
|
||||
}
|
||||
|
||||
var showHelp bool
|
||||
// avoid 'pflag: help requested' error
|
||||
flag.BoolVarP(&showHelp, "help", "h", false, "")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// allow recipient to be passed as an argument
|
||||
recip = flag.Args()
|
||||
// allow recipients to be passed as an argument
|
||||
recipients = flag.Args()
|
||||
|
||||
if Verbose {
|
||||
fmt.Fprintln(os.Stdout, smtpAddr, fromAddr)
|
||||
// if run via `mailpit sendmail ...` then remove `sendmail` from "recipients"
|
||||
if len(recipients) > 0 && recipients[0] == "sendmail" {
|
||||
recipients = recipients[1:]
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(os.Stdin)
|
||||
if showHelp {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ensure -bs is set
|
||||
if UseB && !UseS || !UseB && UseS {
|
||||
fmt.Printf("error: use -bs")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// handles `sendmail -bs`
|
||||
if UseB && UseS {
|
||||
var caller telnet.Caller = telnet.StandardCaller
|
||||
|
||||
// telnet directly to SMTP
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error reading stdin")
|
||||
os.Exit(11)
|
||||
@@ -90,14 +130,14 @@ func Run() {
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Sprintf("error parsing message body: %s", err))
|
||||
fmt.Fprintf(os.Stderr, "error parsing message body: %si\n", err)
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
addresses := []string{}
|
||||
|
||||
if len(recip) > 0 {
|
||||
addresses = recip
|
||||
if len(recipients) > 0 {
|
||||
addresses = recipients
|
||||
} else {
|
||||
// get all recipients in To, Cc and Bcc
|
||||
if to, err := msg.Header.AddressList("To"); err == nil {
|
||||
@@ -117,9 +157,34 @@ func Run() {
|
||||
}
|
||||
}
|
||||
|
||||
err = smtp.SendMail(smtpAddr, nil, fromAddr, addresses, body)
|
||||
from, err := mail.ParseAddress(FromAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "invalid from address")
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
err = smtp.SendMail(SMTPAddr, nil, from.Address, addresses, body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// HelpTemplate returns a string of the help
|
||||
func HelpTemplate(args []string) string {
|
||||
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
|
||||
|
||||
Usage: %s [flags] [recipients] < message
|
||||
|
||||
See: https://github.com/axllent/mailpit
|
||||
|
||||
Flags:
|
||||
-S string SMTP server address (default "localhost:1025")
|
||||
-f string Set the envelope sender address (default "%s")
|
||||
-bs Handle SMTP commands on standard input
|
||||
-t Ignored
|
||||
-i Ignored
|
||||
-o Ignored
|
||||
-v Ignored
|
||||
`, config.Version, strings.Join(args, " "), FromAddr)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package apiv1 handles all the API responses
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
@@ -8,14 +9,18 @@ import (
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
@@ -62,10 +67,11 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
@@ -91,6 +97,12 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: Limit results
|
||||
@@ -109,7 +121,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.Search(search, start, limit)
|
||||
messages, results, err := storage.Search(search, start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -121,8 +133,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages)
|
||||
res.Total = stats.Total
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = results
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
@@ -131,6 +144,44 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/search messages DeleteSearch
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Delete all messages matching a search.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.DeleteSearch(search); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// GetMessage (method: GET) returns the Message as JSON
|
||||
func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID} message Message
|
||||
@@ -139,6 +190,8 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns the summary of a message, marking the message as read.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
@@ -147,7 +200,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -159,6 +212,16 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
@@ -188,7 +251,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message ID
|
||||
// description: Message database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
@@ -229,6 +292,8 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns the message headers as an array.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message headers.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
@@ -237,7 +302,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -249,6 +314,16 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
@@ -276,6 +351,8 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns the full email source as plain text.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message source.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
@@ -284,7 +361,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -295,16 +372,25 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
@@ -313,11 +399,11 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
|
||||
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/messages messages Delete
|
||||
// swagger:route DELETE /api/v1/messages messages DeleteMessages
|
||||
//
|
||||
// # Delete messages
|
||||
//
|
||||
// If no IDs are provided then all messages are deleted.
|
||||
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -327,13 +413,6 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message IDs to delete
|
||||
// required: false
|
||||
// type: DeleteRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
@@ -357,7 +436,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Header().Add("Content-Type", "application/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
@@ -378,13 +457,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message IDs to update
|
||||
// required: false
|
||||
// type: SetReadStatusRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
@@ -440,13 +512,42 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// SetTags (method: PUT) will set the tags for all provided IDs
|
||||
func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
// GetAllTags (method: GET) will get all tags currently in use
|
||||
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/tags tags GetAllTags
|
||||
//
|
||||
// # Get all current tags
|
||||
//
|
||||
// Returns a JSON array of all unique message tags.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ArrayResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
tags := storage.GetAllTags()
|
||||
|
||||
data, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// SetMessageTags (method: PUT) will set the tags for all provided IDs
|
||||
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/tags tags SetTags
|
||||
//
|
||||
// # Set message tags
|
||||
//
|
||||
// To remove all tags from a message, pass an empty tags array.
|
||||
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -456,13 +557,6 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ids
|
||||
// in: body
|
||||
// description: Message IDs to update
|
||||
// required: true
|
||||
// type: SetTagsRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
@@ -484,7 +578,7 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if len(ids) > 0 {
|
||||
for _, id := range ids {
|
||||
if err := storage.SetTags(id, data.Tags); err != nil {
|
||||
if err := storage.SetMessageTags(id, data.Tags); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -495,14 +589,13 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a preconfigured external SMTP server.
|
||||
// If no IDs are provided then all messages are updated.
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route POST /api/v1/message/{ID}/release message Release
|
||||
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
|
||||
//
|
||||
// # Release message
|
||||
//
|
||||
// Release a message via a preconfigured external SMTP server..
|
||||
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
@@ -512,18 +605,6 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: to
|
||||
// in: body
|
||||
// description: Array of email addresses to release message to
|
||||
// required: true
|
||||
// type: ReleaseMessageRequest
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
@@ -540,7 +621,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := releaseMessageRequest{}
|
||||
data := releaseMessageRequestBody{}
|
||||
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
httpError(w, err.Error())
|
||||
@@ -587,7 +668,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
from = senders[0].Address
|
||||
}
|
||||
|
||||
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc", "Message-Id"})
|
||||
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -607,10 +688,21 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
from = config.SMTPRelayConfig.ReturnPath
|
||||
}
|
||||
|
||||
// update message date
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := uuid.NewV4().String() + "@mailpit"
|
||||
// add unique ID
|
||||
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
|
||||
uid := uuid.New().String() + "@mailpit"
|
||||
// update Message-Id with unique ID
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := smtpd.Send(from, tos, msg); err != nil {
|
||||
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
|
||||
@@ -622,6 +714,164 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
|
||||
//
|
||||
// # HTML check (beta)
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// LinkCheck returns a summary of links in the email
|
||||
func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
|
||||
//
|
||||
// # Link check (beta)
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: LinkCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
f := r.URL.Query().Get("follow")
|
||||
followRedirects := f == "true" || f == "1"
|
||||
|
||||
summary, err := linkcheck.RunTests(msg, followRedirects)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
|
||||
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck
|
||||
//
|
||||
// # SpamAssassin check (beta)
|
||||
//
|
||||
// Returns the SpamAssassin (if enabled) summary of the message.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: SpamAssassinResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := spamassassin.Check(msg)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
@@ -659,7 +909,7 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
}
|
||||
|
||||
// GetOptions returns a blank response
|
||||
func GetOptions(w http.ResponseWriter, r *http.Request) {
|
||||
func GetOptions(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte(""))
|
||||
|
||||
@@ -3,34 +3,12 @@ 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"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
)
|
||||
|
||||
// Response includes the current and latest Mailpit version, database info, and memory usage
|
||||
//
|
||||
// swagger:model AppInformation
|
||||
type appInformation struct {
|
||||
// Current Mailpit version
|
||||
Version string
|
||||
// Latest Mailpit version
|
||||
LatestVersion string
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize int64
|
||||
// Total number of messages in the database
|
||||
Messages int
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
}
|
||||
|
||||
// AppInfo returns some basic details about the running app, and latest release.
|
||||
func AppInfo(w http.ResponseWriter, r *http.Request) {
|
||||
func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
//
|
||||
// # Get application information
|
||||
@@ -45,27 +23,8 @@ func AppInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// Responses:
|
||||
// 200: InfoResponse
|
||||
// default: ErrorResponse
|
||||
info := appInformation{}
|
||||
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()
|
||||
info := stats.Load()
|
||||
|
||||
bytes, _ := json.Marshal(info)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
@@ -12,9 +15,14 @@ type MessagesSummary struct {
|
||||
// Total number of unread messages in mailbox
|
||||
Unread int `json:"unread"`
|
||||
|
||||
// Number of results returned
|
||||
// Legacy - now undocumented in API specs but left for backwards compatibility.
|
||||
// Removed from API documentation 2023-07-12
|
||||
// swagger:ignore
|
||||
Count int `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount int `json:"messages_count"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
@@ -22,7 +30,7 @@ type MessagesSummary struct {
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// Messages summary
|
||||
// in:body
|
||||
// in: body
|
||||
Messages []storage.MessageSummary `json:"messages"`
|
||||
}
|
||||
|
||||
@@ -37,3 +45,12 @@ type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
|
||||
// HTMLCheckResponse summary
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// LinkCheckResponse summary
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
// SpamAssassinResponse summary
|
||||
type SpamAssassinResponse = spamassassin.Result
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package apiv1
|
||||
|
||||
// These structs are for the purpose of defining swagger HTTP responses
|
||||
import "github.com/axllent/mailpit/internal/stats"
|
||||
|
||||
// These structs are for the purpose of defining swagger HTTP parameters & responses
|
||||
|
||||
// Application information
|
||||
// swagger:response InfoResponse
|
||||
type infoResponse struct {
|
||||
// Application information
|
||||
Body appInformation
|
||||
//
|
||||
// in: body
|
||||
Body stats.AppInformation
|
||||
}
|
||||
|
||||
// Web UI configuration
|
||||
// swagger:response WebUIConfigurationResponse
|
||||
type webUIConfigurationResponse struct {
|
||||
// Web UI configuration settings
|
||||
//
|
||||
// in: body
|
||||
Body webUIConfiguration
|
||||
}
|
||||
|
||||
@@ -28,71 +34,148 @@ type messagesSummaryResponse struct {
|
||||
// swagger:model MessageHeaders
|
||||
type messageHeaders map[string][]string
|
||||
|
||||
// swagger:parameters DeleteMessages
|
||||
type deleteMessagesParams struct {
|
||||
// in: body
|
||||
Body *deleteMessagesRequestBody
|
||||
}
|
||||
|
||||
// Delete request
|
||||
// swagger:model DeleteRequest
|
||||
type deleteRequest struct {
|
||||
// ids
|
||||
// in:body
|
||||
type deleteMessagesRequestBody struct {
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// swagger:parameters SetReadStatus
|
||||
type setReadStatusParams struct {
|
||||
// in: body
|
||||
Body *setReadStatusRequestBody
|
||||
}
|
||||
|
||||
// Set read status request
|
||||
// swagger:model SetReadStatusRequest
|
||||
type setReadStatusRequest struct {
|
||||
// swagger:model setReadStatusRequestBody
|
||||
type setReadStatusRequestBody struct {
|
||||
// Read status
|
||||
//
|
||||
// required: false
|
||||
// default: false
|
||||
// example: true
|
||||
Read bool `json:"read"`
|
||||
|
||||
// ids
|
||||
// in:body
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// swagger:parameters SetTags
|
||||
type setTagsParams struct {
|
||||
// in: body
|
||||
Body *setTagsRequestBody
|
||||
}
|
||||
|
||||
// Set tags request
|
||||
// swagger:model SetTagsRequest
|
||||
type setTagsRequest struct {
|
||||
// Tags
|
||||
// in:body
|
||||
// swagger:model setTagsRequestBody
|
||||
type setTagsRequestBody struct {
|
||||
// Array of tag names to set
|
||||
//
|
||||
// required: true
|
||||
// example: ["Tag 1", "Tag 2"]
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// IDs
|
||||
// in:body
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: true
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// swagger:parameters ReleaseMessage
|
||||
type releaseMessageParams struct {
|
||||
// Message database ID
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// in: body
|
||||
Body *releaseMessageRequestBody
|
||||
}
|
||||
|
||||
// Release request
|
||||
// swagger:model ReleaseMessageRequest
|
||||
type releaseMessageRequest struct {
|
||||
// To
|
||||
// in:body
|
||||
// swagger:model releaseMessageRequestBody
|
||||
type releaseMessageRequestBody struct {
|
||||
// Array of email addresses to relay the message to
|
||||
//
|
||||
// required: true
|
||||
// example: ["user1@example.com", "user2@example.com"]
|
||||
To []string `json:"to"`
|
||||
}
|
||||
|
||||
// Binary data reponse inherits the attachment's content type
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse struct {
|
||||
// in: body
|
||||
Body string
|
||||
// swagger:parameters HTMLCheck
|
||||
type htmlCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// swagger:parameters LinkCheck
|
||||
type linkCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// Follow redirects
|
||||
//
|
||||
// in: query
|
||||
// description: Follow redirects
|
||||
// required: false
|
||||
// default: false
|
||||
Follow string `json:"follow"`
|
||||
}
|
||||
|
||||
// swagger:parameters SpamAssassinCheck
|
||||
type spamAssassinCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// Binary data response inherits the attachment's content type
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse string
|
||||
|
||||
// Plain text response
|
||||
// swagger:response TextResponse
|
||||
type textResponse struct {
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
type textResponse string
|
||||
|
||||
// Error reponse
|
||||
// HTML response
|
||||
// swagger:response HTMLResponse
|
||||
type htmlResponse string
|
||||
|
||||
// HTTP error response will return with a >= 400 response code
|
||||
// swagger:response ErrorResponse
|
||||
type errorResponse struct {
|
||||
// The error message
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
type errorResponse string
|
||||
|
||||
// Plain text "ok" reponse
|
||||
// Plain text "ok" response
|
||||
// swagger:response OKResponse
|
||||
type okResponse struct {
|
||||
// Default reponse
|
||||
// in: body
|
||||
Body string
|
||||
}
|
||||
type okResponse string
|
||||
|
||||
// Plain JSON array response
|
||||
// swagger:response ArrayResponse
|
||||
type arrayResponse []string
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
@@ -39,12 +39,12 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: message id
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: attachment part id
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -77,7 +77,7 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
img, err := imaging.Decode(buf)
|
||||
if err != nil {
|
||||
// it's not an image, return default
|
||||
logger.Log().Warning(err)
|
||||
logger.Log().Warnf("[image] %s", err.Error())
|
||||
blankImage(a, w)
|
||||
return
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
|
||||
|
||||
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
|
||||
logger.Log().Warning(err)
|
||||
logger.Log().Warnf("[image] %s", err.Error())
|
||||
blankImage(a, w)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func blankImage(a *enmime.Part, w http.ResponseWriter) {
|
||||
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)
|
||||
logger.Log().Warnf("[image] %s", err.Error())
|
||||
}
|
||||
|
||||
fileName := a.FileName
|
||||
|
||||
@@ -23,15 +23,25 @@ type webUIConfiguration struct {
|
||||
// Allowlist of accepted recipients
|
||||
RecipientAllowlist string
|
||||
}
|
||||
|
||||
// Whether the HTML check has been globally disabled
|
||||
DisableHTMLCheck bool
|
||||
|
||||
// Whether SpamAssassin is enabled
|
||||
SpamAssassin bool
|
||||
|
||||
// Whether messages with duplicate IDs are ignored
|
||||
DuplicatesIgnored bool
|
||||
}
|
||||
|
||||
// WebUIConfig returns configuration settings for the web UI.
|
||||
func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/webui application WebUIConfiguration
|
||||
//
|
||||
// # Get web UI configuration
|
||||
//
|
||||
// Returns configuration settings for the web UI.
|
||||
// Intended for web UI only!
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -50,6 +60,10 @@ func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
|
||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
|
||||
@@ -2,7 +2,7 @@ package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Healthz is a liveness probe
|
||||
// HealthzHandler is a liveness probe
|
||||
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
185
server/handlers/messages.go
Normal file
185
server/handlers/messages.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// RedirectToLatestMessage (method: GET) redirects the web UI to the latest message
|
||||
func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var messages []storage.MessageSummary
|
||||
var err error
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = storage.Search(search, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
messages, err = storage.List(0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uri := config.Webroot
|
||||
|
||||
if len(messages) == 1 {
|
||||
uri, err = url.JoinPath(uri, "/view/"+messages[0].ID)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, uri, 302)
|
||||
}
|
||||
|
||||
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
|
||||
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.html testing GetMessageHTML
|
||||
//
|
||||
// # Render message HTML part
|
||||
//
|
||||
// Renders just the message's HTML part which can be used for UI integration testing.
|
||||
// Attached inline images are modified to link to the API provided they exist.
|
||||
// Note that is the message does not contain a HTML part then an 404 error is returned.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/html
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
if msg.HTML == "" {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "This message does not contain a HTML part")
|
||||
return
|
||||
}
|
||||
|
||||
html := linkInlineImages(msg)
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// GetMessageText (method: GET) returns a message's text part
|
||||
func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.txt testing GetMessageText
|
||||
//
|
||||
// # Render message text part
|
||||
//
|
||||
// Renders just the message's text part which can be used for UI integration testing.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(msg.Text))
|
||||
}
|
||||
|
||||
// This will rewrite all inline image paths to API URLs
|
||||
func linkInlineImages(msg *storage.Message) string {
|
||||
html := msg.HTML
|
||||
|
||||
for _, a := range msg.Inline {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range msg.Attachments {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
155
server/handlers/proxy.go
Normal file
155
server/handlers/proxy.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package handlers contains a specific handlers
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
|
||||
|
||||
// ProxyHandler is used to proxy assets for printing
|
||||
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
uri := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
if uri == "" {
|
||||
logger.Log().Warn("[proxy] URL missing")
|
||||
httpError(w, "Error: URL missing")
|
||||
return
|
||||
}
|
||||
|
||||
if !linkRe.MatchString(uri) {
|
||||
logger.Log().Warnf("[proxy] invalid URL %s", uri)
|
||||
httpError(w, "Error: invalid URL")
|
||||
return
|
||||
}
|
||||
|
||||
tr := &http.Transport{}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", uri, nil)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// use requesting useragent
|
||||
req.Header.Set("User-Agent", r.UserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// relay common headers
|
||||
if resp.Header.Get("content-type") != "" {
|
||||
w.Header().Set("content-type", resp.Header.Get("content-type"))
|
||||
}
|
||||
if resp.Header.Get("last-modified") != "" {
|
||||
w.Header().Set("last-modified", resp.Header.Get("last-modified"))
|
||||
}
|
||||
if resp.Header.Get("content-disposition") != "" {
|
||||
w.Header().Set("content-disposition", resp.Header.Get("content-disposition"))
|
||||
}
|
||||
if resp.Header.Get("cache-control") != "" {
|
||||
w.Header().Set("cache-control", resp.Header.Get("cache-control"))
|
||||
}
|
||||
|
||||
// replace url() values with proxy address, eg: fonts & images
|
||||
if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") {
|
||||
var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`)
|
||||
body = re.ReplaceAllFunc(body, func(s []byte) []byte {
|
||||
parts := re.FindStringSubmatch(string(s))
|
||||
|
||||
// don't resolve inline `data:..`
|
||||
if strings.HasPrefix(parts[3], "data:") {
|
||||
return []byte(parts[3])
|
||||
}
|
||||
|
||||
address, err := absoluteURL(parts[3], uri)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[proxy] %s", err.Error())
|
||||
return []byte(parts[3])
|
||||
}
|
||||
|
||||
return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")")
|
||||
})
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode)
|
||||
|
||||
// relay status code - WriteHeader must come after Header.Set()
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
|
||||
func absoluteURL(link, baseURL string) (string, error) {
|
||||
// scheme relative links, eg <script src="//example.com/script.js">
|
||||
if len(link) > 1 && link[0:2] == "//" {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
link = base.Scheme + ":" + link
|
||||
}
|
||||
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
|
||||
// remove hashes
|
||||
u.Fragment = ""
|
||||
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return link, err
|
||||
}
|
||||
|
||||
result := base.ResolveReference(u)
|
||||
|
||||
// ensure link is HTTP(S)
|
||||
if result.Scheme != "http" && result.Scheme != "https" {
|
||||
return link, fmt.Errorf("Invalid URL: %s", result.String())
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
167
server/server.go
167
server/server.go
@@ -2,20 +2,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/server/handlers"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -29,10 +35,11 @@ var AccessControlAllowOrigin string
|
||||
func Listen() {
|
||||
isReady := &atomic.Value{}
|
||||
isReady.Store(false)
|
||||
stats.Track()
|
||||
|
||||
serverRoot, err := fs.Sub(embeddedFS, "ui")
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[http] %s", err)
|
||||
logger.Log().Errorf("[http] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -40,59 +47,92 @@ func Listen() {
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
|
||||
r := defaultRoutes()
|
||||
r := apiRoutes()
|
||||
|
||||
// kubernetes probes
|
||||
r.HandleFunc("/livez", handlers.HealthzHandler)
|
||||
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
|
||||
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
|
||||
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
// proxy handler for screenshots
|
||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||
|
||||
// virtual filesystem for others
|
||||
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
// virtual filesystem for /dist/ & some individual files
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "notification.png").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")
|
||||
redirect := strings.TrimRight(config.Webroot, "/")
|
||||
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
|
||||
}
|
||||
|
||||
// UI shortcut
|
||||
r.HandleFunc(config.Webroot+"view/latest", handlers.RedirectToLatestMessage).Methods("GET")
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
|
||||
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
|
||||
|
||||
// put it all together
|
||||
http.Handle("/", r)
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
logger.Log().Info("[http] enabling web UI basic authentication")
|
||||
if auth.UICredentials != nil {
|
||||
logger.Log().Info("[http] enabling basic authentication")
|
||||
}
|
||||
|
||||
// Mark the application here as ready
|
||||
isReady.Store(true)
|
||||
|
||||
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
|
||||
|
||||
if config.UITLSCert != "" && config.UITLSKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRoutes() *mux.Router {
|
||||
func apiRoutes() *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/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
|
||||
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}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
if !config.DisableHTMLCheck {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||
}
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
|
||||
if config.EnableSpamAssassin != "" {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).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")
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
// return blank 200 response for OPTIONS requests for CORS
|
||||
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
|
||||
@@ -129,7 +169,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
if auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -137,7 +177,21 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !config.UIAuth.Match(user, pass) {
|
||||
if !auth.UICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.UICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
@@ -168,7 +222,7 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if config.UIAuthFile != "" {
|
||||
if auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -176,7 +230,7 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if !config.UIAuth.Match(user, pass) {
|
||||
if !auth.UICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
@@ -201,4 +255,75 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
// Websocket to broadcast changes
|
||||
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
websockets.ServeWs(websockets.MessageHub, w, r)
|
||||
storage.BroadcastMailboxStats()
|
||||
}
|
||||
|
||||
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
|
||||
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if config.Webroot != "/" {
|
||||
// artificially inject a path at the start
|
||||
replacement := fmt.Sprintf("{\n \"basePath\": \"%s\",", strings.TrimRight(config.Webroot, "/"))
|
||||
|
||||
f = bytes.Replace(f, []byte("{"), []byte(replacement), 1)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(f)
|
||||
}
|
||||
|
||||
// Just returns the default HTML template
|
||||
func index(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
var h = `<!DOCTYPE html>
|
||||
<html lang="en" class="h-100">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<link rel="icon" href="{{ .Webroot }}favicon.svg">
|
||||
<title>Mailpit</title>
|
||||
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
|
||||
</head>
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
|
||||
<noscript>You require JavaScript to use this app.</noscript>
|
||||
</div>
|
||||
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
|
||||
t, err := template.New("index").Parse(h)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Webroot string
|
||||
Version string
|
||||
}{
|
||||
Webroot: config.Webroot,
|
||||
Version: config.Version,
|
||||
}
|
||||
|
||||
buff := new(bytes.Buffer)
|
||||
|
||||
err = t.Execute(buff, data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buff.Bytes()
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
@@ -25,11 +25,11 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func Test_APIv1(t *testing.T) {
|
||||
func TestAPIv1Messages(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := defaultRoutes()
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -54,11 +54,11 @@ func Test_APIv1(t *testing.T) {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10
|
||||
// read first 10 messages
|
||||
t.Log("Read first 10 messages including raw & headers")
|
||||
putIDS := []string{}
|
||||
for indx, msg := range m.Messages {
|
||||
if indx == 10 {
|
||||
for idx, msg := range m.Messages {
|
||||
if idx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ func Test_APIv1(t *testing.T) {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// test RAW
|
||||
// get RAW
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// test headers
|
||||
// het headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
@@ -79,11 +79,63 @@ func Test_APIv1(t *testing.T) {
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
|
||||
// 10 should be marked as read
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// 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 TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
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)
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 IDs
|
||||
t.Log("Get first 10 IDs")
|
||||
putIDS := []string{}
|
||||
for idx, msg := range m.Messages {
|
||||
if idx == 10 {
|
||||
break
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark first 10 as unread
|
||||
t.Log("Mark first 10 as unread")
|
||||
t.Log("Mark first 10 as read")
|
||||
putData := putDataStruct
|
||||
putData.Read = true
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
@@ -93,11 +145,11 @@ func Test_APIv1(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
// mark first 10 as read
|
||||
t.Log("Mark first 10 as read")
|
||||
putData.Read = true
|
||||
t.Log("Mark first 10 as unread")
|
||||
putData.Read = false
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
@@ -106,25 +158,7 @@ func Test_APIv1(t *testing.T) {
|
||||
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)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// mark all as read
|
||||
putData.Read = true
|
||||
@@ -139,15 +173,38 @@ func Test_APIv1(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
|
||||
}
|
||||
|
||||
// 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 TestAPIv1Search(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages & tag")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// search
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:from-1@example.com", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-from:from-1@example.com", 99)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "-FROM:FROM-1@EXAMPLE.COM", 99)
|
||||
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)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "tag:\"Test tag 065\"", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "tag:\"TEST TAG 065\"", 1)
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99)
|
||||
}
|
||||
|
||||
func setup() {
|
||||
@@ -195,7 +252,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.Count, "wrong search results count")
|
||||
assertEqual(t, count, m.MessagesCount, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
@@ -219,7 +276,15 @@ func insertEmailData(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := storage.Store(buf.Bytes()); err != nil {
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
id, err := storage.Store(&bufBytes)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -253,7 +318,7 @@ func clientGet(url string) ([]byte, error) {
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
@@ -278,7 +343,7 @@ func clientDelete(url, body string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
@@ -303,7 +368,7 @@ func clientPut(url, body string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/smtp"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func allowedRecipients(to []string) []string {
|
||||
@@ -48,7 +48,7 @@ func Send(from string, to []string, msg []byte) error {
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
@@ -59,47 +59,89 @@ func Send(from string, to []string, msg []byte) error {
|
||||
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
auth := relayAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP relay authentication based on config
|
||||
func relayAuthFromConfig() smtp.Auth {
|
||||
var a smtp.Auth
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "plain" {
|
||||
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "login" {
|
||||
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.Auth == "cram-md5" {
|
||||
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
|
||||
}
|
||||
|
||||
if a != nil {
|
||||
if err = c.Auth(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = c.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, addr := range recipients {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
return a
|
||||
}
|
||||
|
||||
// Custom implementation of LOGIN SMTP authentication
|
||||
// @see https://gist.github.com/andelf/5118732
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
// LoginAuth authentication
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("Unknown fromServer")
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -10,31 +10,66 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhale/smtpd"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
// DisableReverseDNS allows rDNS to be disabled
|
||||
DisableReverseDNS bool
|
||||
)
|
||||
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
if !config.SMTPStrictRFCHeaders {
|
||||
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
|
||||
stats.LogSMTPRejected()
|
||||
return err
|
||||
}
|
||||
|
||||
// check / set the Return-Path based on SMTP from
|
||||
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
|
||||
if returnPath != from {
|
||||
if returnPath != "" {
|
||||
// replace Return-Path
|
||||
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
|
||||
replaced := false
|
||||
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
|
||||
})
|
||||
} else {
|
||||
// add Return-Path
|
||||
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
|
||||
|
||||
// add a message ID if not set
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
messageID = uuid.NewV4().String() + "@mailpit"
|
||||
messageID = uuid.New().String() + "@mailpit"
|
||||
// add unique ID
|
||||
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
|
||||
} else if config.IgnoreDuplicateIDs {
|
||||
if storage.MessageIDExists(messageID) {
|
||||
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
|
||||
stats.LogSMTPIgnored()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -74,7 +109,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
if replaced {
|
||||
return r
|
||||
}
|
||||
replaced = true // only replace first occurence
|
||||
replaced = true // only replace first occurrence
|
||||
|
||||
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
|
||||
})
|
||||
@@ -88,21 +123,24 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
|
||||
}
|
||||
|
||||
_, err = storage.Store(data)
|
||||
_, err = storage.Store(&data)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %d", err.Error())
|
||||
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
stats.LogSMTPAccepted(len(data))
|
||||
|
||||
data = nil // avoid memory leaks
|
||||
|
||||
subject := msg.Header.Get("Subject")
|
||||
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
|
||||
allow := config.SMTPAuthConfig.Match(string(username), string(password))
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
|
||||
allow := auth.SMTPCredentials.Match(string(username), string(password))
|
||||
if allow {
|
||||
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
} else {
|
||||
@@ -113,48 +151,67 @@ func authHandler(remoteAddr net.Addr, mechanism string, username []byte, passwor
|
||||
}
|
||||
|
||||
// Allow any username and password
|
||||
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
|
||||
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
|
||||
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HandlerRcpt used to optionally restrict recipients based on `--smtp-allowed-recipients`
|
||||
func handlerRcpt(remoteAddr net.Addr, from string, to string) bool {
|
||||
if config.SMTPAllowedRecipientsRegexp == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
result := config.SMTPAllowedRecipientsRegexp.MatchString(to)
|
||||
|
||||
if !result {
|
||||
logger.Log().Warnf("[smtpd] rejected message to %s from %s (%s)", to, from, cleanIP(remoteAddr))
|
||||
stats.LogSMTPRejected()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Listen starts the SMTPD server
|
||||
func Listen() error {
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
if config.SMTPAuthFile != "" {
|
||||
logger.Log().Infof("[smtpd] enabling login auth via %s (insecure)", config.SMTPAuthFile)
|
||||
if auth.SMTPCredentials != nil {
|
||||
logger.Log().Info("[smtpd] enabling login auth (insecure)")
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling all auth (insecure)")
|
||||
}
|
||||
} else {
|
||||
if config.SMTPAuthFile != "" {
|
||||
logger.Log().Infof("[smtpd] enabling login auth via %s (TLS)", config.SMTPAuthFile)
|
||||
if auth.SMTPCredentials != nil {
|
||||
logger.Log().Info("[smtpd] enabling login auth (TLS)")
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling any auth (TLS)")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
|
||||
logger.Log().Infof("[smtpd] 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,
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
Appname: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
}
|
||||
|
||||
if config.SMTPAuthFile != "" {
|
||||
if auth.SMTPCredentials != nil {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthHandler = authHandler
|
||||
srv.AuthRequired = true
|
||||
@@ -164,6 +221,7 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
|
||||
}
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
srv.TLSRequired = config.SMTPTLSRequired
|
||||
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import "./assets/styles.scss";
|
||||
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
|
||||
import "bootstrap";
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
createApp(App).mount('#app');
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
51
server/ui-src/assets/_bootstrap.scss
Normal file
51
server/ui-src/assets/_bootstrap.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
@import "bootstrap/scss/card";
|
||||
@import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
// @import "bootstrap/scss/popover";
|
||||
// @import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas";
|
||||
// @import "bootstrap/scss/popover";
|
||||
@import "bootstrap/scss/progress";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1,7 +1,21 @@
|
||||
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
|
||||
$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans",
|
||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
$font-family-sans-serif:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
"Noto Sans",
|
||||
"Liberation Sans",
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
|
||||
$link-decoration: none;
|
||||
$primary: #2c3e50;
|
||||
$list-group-disabled-color: #adb5bd;
|
||||
$enable-negative-margins: true;
|
||||
$body-color-dark: #e7eaed;
|
||||
$offcanvas-border-width: 0;
|
||||
|
||||
49
server/ui-src/assets/bootstrap.scss
vendored
49
server/ui-src/assets/bootstrap.scss
vendored
@@ -1,49 +0,0 @@
|
||||
@import "_bootstrap_variables";
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "../../../node_modules/bootstrap/scss/functions";
|
||||
@import "../../../node_modules/bootstrap/scss/variables";
|
||||
@import "../../../node_modules/bootstrap/scss/maps";
|
||||
@import "../../../node_modules/bootstrap/scss/mixins";
|
||||
@import "../../../node_modules/bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "../../../node_modules/bootstrap/scss/root";
|
||||
@import "../../../node_modules/bootstrap/scss/reboot";
|
||||
@import "../../../node_modules/bootstrap/scss/type";
|
||||
@import "../../../node_modules/bootstrap/scss/images";
|
||||
@import "../../../node_modules/bootstrap/scss/containers";
|
||||
@import "../../../node_modules/bootstrap/scss/grid";
|
||||
// @import "../../../node_modules/bootstrap/scss/tables";
|
||||
@import "../../../node_modules/bootstrap/scss/forms";
|
||||
@import "../../../node_modules/bootstrap/scss/buttons";
|
||||
// @import "../../../node_modules/bootstrap/scss/transitions";
|
||||
@import "../../../node_modules/bootstrap/scss/dropdown";
|
||||
@import "../../../node_modules/bootstrap/scss/button-group";
|
||||
@import "../../../node_modules/bootstrap/scss/nav";
|
||||
@import "../../../node_modules/bootstrap/scss/navbar";
|
||||
@import "../../../node_modules/bootstrap/scss/card";
|
||||
// @import "../../../node_modules/bootstrap/scss/accordion";
|
||||
// @import "../../../node_modules/bootstrap/scss/breadcrumb";
|
||||
// @import "../../../node_modules/bootstrap/scss/pagination";
|
||||
@import "../../../node_modules/bootstrap/scss/badge";
|
||||
// @import "../../../node_modules/bootstrap/scss/alert";
|
||||
// @import "../../../node_modules/bootstrap/scss/progress";
|
||||
@import "../../../node_modules/bootstrap/scss/list-group";
|
||||
@import "../../../node_modules/bootstrap/scss/close";
|
||||
@import "../../../node_modules/bootstrap/scss/toasts";
|
||||
@import "../../../node_modules/bootstrap/scss/modal";
|
||||
// @import "../../../node_modules/bootstrap/scss/tooltip";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
// @import "../../../node_modules/bootstrap/scss/carousel";
|
||||
@import "../../../node_modules/bootstrap/scss/spinners";
|
||||
// @import "../../../node_modules/bootstrap/scss/offcanvas";
|
||||
// @import "../../../node_modules/bootstrap/scss/popover";
|
||||
|
||||
// Helpers
|
||||
@import "../../../node_modules/bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "../../../node_modules/bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
@@ -1,333 +1,420 @@
|
||||
@import "bootstrap";
|
||||
@import "./bootstrap";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 99;
|
||||
z-index: 99;
|
||||
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
transition: all 0.2s;
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
transition: all 0.2s;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
span {
|
||||
opacity: 0.8;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
span {
|
||||
opacity: 0.8;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
@include media-breakpoint-down(sm) {
|
||||
// font-size: 14px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
@include media-breakpoint-down(xl) {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
:not(.text-view) > a:not(.no-icon) {
|
||||
&[href^="http://"],
|
||||
&[href^="https://"]
|
||||
{
|
||||
&:after {
|
||||
content: "\f1c5";
|
||||
display: inline-block;
|
||||
font-family: "bootstrap-icons" !important;
|
||||
font-style: normal;
|
||||
font-weight: normal !important;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message.read:not(.active):not(.selected) {
|
||||
color: $gray-500;
|
||||
.link {
|
||||
@extend a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
// dark mode adjustments
|
||||
@include color-mode(dark) {
|
||||
.loader {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.property {
|
||||
color: #ee6969;
|
||||
}
|
||||
}
|
||||
|
||||
.about-mailpit {
|
||||
@include media-breakpoint-down(md) {
|
||||
width: var(--bs-offcanvas-width);
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.text-spaces-nowrap {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.text-spaces {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#nav-plain-text .text-view,
|
||||
#nav-source {
|
||||
white-space: pre;
|
||||
font-family: Courier New, Courier, System, fixed-width;
|
||||
font-size: 0.85em;
|
||||
white-space: pre;
|
||||
font-family: "Courier New", Courier, System, fixed-width;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#nav-html-source pre[class*="language-"] code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#nav-plain-text .text-view {
|
||||
white-space: pre-wrap;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.messageHeaders {
|
||||
margin: 15px 0 0;
|
||||
margin: 15px 0 0;
|
||||
|
||||
th {
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
#nav-html {
|
||||
padding-right: 1.5rem;
|
||||
@include media-breakpoint-up(md) {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#preview-html {
|
||||
min-height: 300px;
|
||||
min-height: 300px;
|
||||
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border: solid $gray-300 1px;
|
||||
}
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border: solid $gray-300 1px;
|
||||
}
|
||||
}
|
||||
|
||||
#responsive-view {
|
||||
margin: auto;
|
||||
transition: width 0.5s;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
transition: width 0.5s;
|
||||
position: relative;
|
||||
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border-radius: 35px;
|
||||
box-sizing: content-box;
|
||||
padding-bottom: 76px;
|
||||
padding-top: 54px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: $gray-800;
|
||||
&.tablet,
|
||||
&.phone {
|
||||
border-radius: 35px;
|
||||
box-sizing: content-box;
|
||||
padding-bottom: 76px;
|
||||
padding-top: 54px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: $gray-800;
|
||||
|
||||
iframe {
|
||||
height: 100% !important;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
iframe {
|
||||
height: 100% !important;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.phone {
|
||||
&::before {
|
||||
border-radius: 5px;
|
||||
background: $gray-600;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
}
|
||||
&.phone {
|
||||
&::before {
|
||||
border-radius: 5px;
|
||||
background: $gray-600;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-radius: 20px;
|
||||
background: $gray-900;
|
||||
bottom: 20px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 65px;
|
||||
height: 40px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
border-radius: 20px;
|
||||
background: $gray-900;
|
||||
bottom: 20px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 65px;
|
||||
height: 40px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.tablet {
|
||||
&::before {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
&.tablet {
|
||||
&::before {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
top: 22px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
bottom: 23px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
border-radius: 50%;
|
||||
border: solid #b5b0b0 2px;
|
||||
bottom: 23px;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageHeaders {
|
||||
th {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.message.selected {
|
||||
background: $gray-300;
|
||||
|
||||
.text-muted {
|
||||
color: $body-color !important;
|
||||
}
|
||||
|
||||
&.read {
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
body.blur {
|
||||
.privacy {
|
||||
filter: blur(3px);
|
||||
}
|
||||
.privacy {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.card.attachment {
|
||||
color: $gray-800;
|
||||
color: $gray-800;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 3.5rem;
|
||||
text-align: center;
|
||||
color: $gray-300;
|
||||
}
|
||||
.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-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: $gray-300;
|
||||
.card-footer {
|
||||
background: $gray-300;
|
||||
|
||||
.bi {
|
||||
font-size: 1.3em;
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
.bi {
|
||||
font-size: 1.3em;
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.card-body {
|
||||
opacity: 1;
|
||||
background: $gray-300;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.card-body {
|
||||
opacity: 1;
|
||||
background: $gray-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-select.tag-selector {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
input {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
input {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: text; // html5-tags
|
||||
}
|
||||
div {
|
||||
cursor: text; // html5-tags
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu.checks {
|
||||
.dropdown-item {
|
||||
min-width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrap5-tags
|
||||
.tags-badge {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#DownloadBtn {
|
||||
@include media-breakpoint-down(sm) {
|
||||
position: static;
|
||||
@include media-breakpoint-down(sm) {
|
||||
position: static;
|
||||
|
||||
.dropdown-menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ReleaseModal {
|
||||
.form-control.dropdown {
|
||||
div {
|
||||
@extend .form-control;
|
||||
}
|
||||
}
|
||||
.form-control.dropdown {
|
||||
div {
|
||||
@extend .form-control;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
// 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;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
pre[class*="language-"] > code {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
code[class*="language-"] {
|
||||
max-height: inherit;
|
||||
height: inherit;
|
||||
padding: 0 1em;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
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;
|
||||
// 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;
|
||||
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,
|
||||
@@ -335,10 +422,10 @@ pre[class*="language-"] {
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: #7d8b99;
|
||||
color: #7d8b99;
|
||||
}
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
color: #5f6364;
|
||||
}
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
@@ -348,7 +435,7 @@ pre[class*="language-"] {
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: #c92c2c;
|
||||
color: #c92c2c;
|
||||
}
|
||||
.token.attr-name,
|
||||
.token.builtin,
|
||||
@@ -357,70 +444,70 @@ pre[class*="language-"] {
|
||||
.token.inserted,
|
||||
.token.selector,
|
||||
.token.string {
|
||||
color: #2f9c0a;
|
||||
color: #2f9c0a;
|
||||
}
|
||||
.token.entity,
|
||||
.token.operator,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.class-name,
|
||||
.token.keyword {
|
||||
color: #1990b8;
|
||||
color: #1990b8;
|
||||
}
|
||||
.token.important,
|
||||
.token.regex {
|
||||
color: #e90;
|
||||
color: #e90;
|
||||
}
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: #a67f59;
|
||||
// background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.token.important {
|
||||
font-weight: 400;
|
||||
font-weight: 400;
|
||||
}
|
||||
.token.bold {
|
||||
font-weight: 700;
|
||||
font-weight: 700;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
font-style: italic;
|
||||
}
|
||||
// .token.entity {
|
||||
// cursor: help;
|
||||
// }
|
||||
.token.namespace {
|
||||
opacity: 0.7;
|
||||
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-"]::after,
|
||||
pre[class*="language-"]::before {
|
||||
bottom: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers {
|
||||
padding-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
left: 0;
|
||||
}
|
||||
pre[class*="language-"][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
314
server/ui-src/components/AboutMailpit.vue
Normal file
314
server/ui-src/components/AboutMailpit.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: 'auto',
|
||||
icon: 'circle-half',
|
||||
icons: {
|
||||
'auto': 'circle-half',
|
||||
'light': 'sun-fill',
|
||||
'dark': 'moon-stars-fill'
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme(this.getPreferredTheme())
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo: function () {
|
||||
let self = this
|
||||
self.get(self.resolve('/api/v1/info'), false, function (response) {
|
||||
mailbox.appInfo = response.data
|
||||
self.modal('AppInfoModal').show()
|
||||
})
|
||||
},
|
||||
|
||||
getStoredTheme: function () {
|
||||
let theme = localStorage.getItem('theme')
|
||||
if (!theme) {
|
||||
theme = 'auto'
|
||||
}
|
||||
|
||||
return theme
|
||||
},
|
||||
|
||||
setStoredTheme: function (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
this.setTheme(theme)
|
||||
},
|
||||
|
||||
getPreferredTheme: function () {
|
||||
const storedTheme = this.getStoredTheme()
|
||||
if (storedTheme) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
},
|
||||
|
||||
setTheme: function (theme) {
|
||||
this.icon = this.icons[theme]
|
||||
this.theme = theme
|
||||
if (
|
||||
theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function () {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notifications")
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
let self = this
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
|
||||
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
|
||||
<i :class="'bi bi-' + icon + ' my-1'"></i>
|
||||
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
|
||||
<i class="bi bi-sun-fill me-2 opacity-50"></i>
|
||||
Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
|
||||
<i class="bi bi-moon-stars-fill me-2 opacity-50"></i>
|
||||
Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
|
||||
<i class="bi bi-circle-half me-2 opacity-50"></i>
|
||||
Auto
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" v-if="mailbox.appInfo.RuntimeStats">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="AppInfoModalLabel">
|
||||
Mailpit
|
||||
<code>({{ mailbox.appInfo.Version }})</code>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="alert alert-warning mb-3" v-if="mailbox.appInfo.LatestVersion == ''">
|
||||
There might be a newer version available. The check failed.
|
||||
</div>
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
</a>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<RouterLink to="/api/v1/" class="btn btn-primary w-100" target="_blank">
|
||||
<i class="bi bi-braces"></i>
|
||||
OpenAPI / Swagger API documentation
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit"
|
||||
target="_blank">
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://mailpit.axllent.org/docs/"
|
||||
target="_blank">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">Database size</div>
|
||||
<div class="card-body text-secondary">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} </h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card border-secondary text-center">
|
||||
<div class="card-header">RAM usage</div>
|
||||
<div class="card-body text-secondary">
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.RuntimeStats.Memory)
|
||||
}} </h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" v-on:click="loadInfo">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body text-secondary">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Mailpit uptime
|
||||
</td>
|
||||
<td>
|
||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Messages deleted
|
||||
</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.MessagesDeleted) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages accepted
|
||||
</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-secondary">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SMTP messages rejected
|
||||
</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPRejected) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mailbox.uiConfig.DuplicatesIgnored">
|
||||
<td>
|
||||
SMTP messages ignored
|
||||
</td>
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPIgnored) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="requestNotifications">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
17
server/ui-src/components/AjaxLoader.vue
Normal file
17
server/ui-src/components/AjaxLoader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Number,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader" v-if="loading > 0">
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="spinner-border text-secondary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
123
server/ui-src/components/Favicon.vue
Normal file
123
server/ui-src/components/Favicon.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
favicon: false,
|
||||
iconPath: false,
|
||||
iconTextColor: '#ffffff',
|
||||
iconBgColor: '#dd0000',
|
||||
iconFontSize: 40,
|
||||
iconProcessing: false,
|
||||
iconTimeout: 500,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.favicon = document.head.querySelector('link[rel="icon"]')
|
||||
if (this.favicon) {
|
||||
this.iconPath = this.favicon.href
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
count() {
|
||||
let i = mailbox.unread
|
||||
if (i > 1000) {
|
||||
i = Math.floor(i / 1000) + 'k'
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
count() {
|
||||
if (!this.favicon || this.iconProcessing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
let self = this
|
||||
|
||||
window.setTimeout(() => {
|
||||
self.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async icoUpdate() {
|
||||
if (!this.favicon) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.count) {
|
||||
this.iconProcessing = false
|
||||
this.favicon.href = this.iconPath
|
||||
return
|
||||
}
|
||||
|
||||
let fontSize = this.iconFontSize
|
||||
// Draw badge text
|
||||
let textPaddingX = 7
|
||||
let textPaddingY = 3
|
||||
|
||||
let strlen = this.count.toString().length
|
||||
|
||||
if (strlen > 2) {
|
||||
// if text >= 3 characters then reduce size and padding
|
||||
textPaddingX = 4
|
||||
fontSize = strlen > 3 ? 30 : 36
|
||||
}
|
||||
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
|
||||
let ctx = canvas.getContext('2d')
|
||||
|
||||
// Draw base icon
|
||||
let icon = new Image()
|
||||
icon.src = this.iconPath
|
||||
await icon.decode()
|
||||
|
||||
ctx.drawImage(icon, 0, 0, 64, 64)
|
||||
|
||||
// Measure text
|
||||
ctx.font = `${fontSize}px Arial, sans-serif`
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'top'
|
||||
let textMetrics = ctx.measureText(this.count)
|
||||
|
||||
// Draw badge
|
||||
let paddingX = 7
|
||||
let paddingY = 4
|
||||
let cornerRadius = 8
|
||||
|
||||
let width = textMetrics.width + paddingX * 2
|
||||
let height = fontSize + paddingY * 2
|
||||
let x = canvas.width - width
|
||||
let y = canvas.height - height - 1
|
||||
|
||||
ctx.fillStyle = this.iconBgColor
|
||||
ctx.roundRect(x, y, width, height, cornerRadius)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = this.iconTextColor
|
||||
ctx.fillText(
|
||||
this.count,
|
||||
canvas.width - textPaddingX,
|
||||
canvas.height - fontSize - textPaddingY
|
||||
)
|
||||
|
||||
this.iconProcessing = false
|
||||
|
||||
this.favicon.href = canvas.toDataURL("image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
177
server/ui-src/components/ListMessages.vue
Normal file
177
server/ui-src/components/ListMessages.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
CommonMixins
|
||||
],
|
||||
|
||||
props: {
|
||||
loadingMessages: Number, // use different name to `loading` as that is already in use in CommonMixins
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
past: "%s ago",
|
||||
s: 'seconds',
|
||||
ss: '%d secs',
|
||||
m: "a minute",
|
||||
mm: "%d mins",
|
||||
h: "an hour",
|
||||
hh: "%d hours",
|
||||
d: "a day",
|
||||
dd: "%d days",
|
||||
w: "a week",
|
||||
ww: "%d weeks",
|
||||
M: "a month",
|
||||
MM: "%d months",
|
||||
y: "a year",
|
||||
yy: "%d years"
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
getRelativeCreated: function (message) {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString()
|
||||
},
|
||||
|
||||
getPrimaryEmailTo: function (message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
},
|
||||
|
||||
isSelected: function (id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
|
||||
toggleSelected: function (e, id) {
|
||||
e.preventDefault()
|
||||
|
||||
if (this.isSelected(id)) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
} else {
|
||||
mailbox.selected.push(id)
|
||||
}
|
||||
},
|
||||
|
||||
selectRange: function (e, id) {
|
||||
e.preventDefault()
|
||||
|
||||
let selecting = false
|
||||
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
|
||||
if (lastSelected == id) {
|
||||
mailbox.selected = mailbox.selected.filter(function (ele) {
|
||||
return ele != id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (lastSelected === false) {
|
||||
mailbox.selected.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
for (let d of mailbox.messages) {
|
||||
if (selecting) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
}
|
||||
if (d.ID == lastSelected || d.ID == id) {
|
||||
// reached backwards select
|
||||
break
|
||||
}
|
||||
} else if (d.ID == id || d.ID == lastSelected) {
|
||||
if (!this.isSelected(d.ID)) {
|
||||
mailbox.selected.push(d.ID)
|
||||
}
|
||||
selecting = true
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" :id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)">
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-lg-block privacy">
|
||||
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</b>
|
||||
</div>
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div class="subject text-truncate text-spaces-nowrap">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div v-if="message.Snippet != ''" class="small text-muted text-truncate">
|
||||
{{ message.Snippet }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-center mt-5">
|
||||
<span v-if="loadingMessages > 0" class="text-secondary">
|
||||
Loading messages...
|
||||
</span>
|
||||
<template v-else-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
|
||||
<template v-else>No messages in your mailbox</template>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
139
server/ui-src/components/NavMailbox.vue
Normal file
139
server/ui-src/components/NavMailbox.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from "./AjaxLoader.vue"
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadInbox: function () {
|
||||
pagination.start = 0
|
||||
this.loadMessages()
|
||||
},
|
||||
|
||||
|
||||
loadMessages: function () {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
markAllRead: function () {
|
||||
let self = this
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
deleteAllMessages: function () {
|
||||
let self = this
|
||||
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
|
||||
pagination.start = 0
|
||||
self.loadMessages()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
|
||||
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(mailbox.unread) }}
|
||||
message<span v-if="mailbox.unread > 1">s</span> as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="markAllRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
103
server/ui-src/components/NavSearch.vue
Normal file
103
server/ui-src/components/NavSearch.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script>
|
||||
import NavSelected from '../components/NavSelected.vue'
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
NavSelected,
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
props: {
|
||||
modals: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
this.hideNav() // hide mobile menu
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
deleteAllMessages: function () {
|
||||
let s = this.getSearch()
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
|
||||
let self = this
|
||||
|
||||
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
this.delete(uri, false, function (response) {
|
||||
self.$router.push('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Inbox</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<NavSelected @loadMessages="loadMessages" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will permanently delete {{ formatNumber(mailbox.count) }}
|
||||
message<span v-if="mailbox.count > 1">s</span> matching
|
||||
<code>{{ getSearch() }}</code>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
|
||||
v-on:click="deleteAllMessages">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
120
server/ui-src/components/NavSelected.vue
Normal file
120
server/ui-src/components/NavSelected.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader,
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMessages: function () {
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
// mark selected messages as read
|
||||
markSelectedRead: function () {
|
||||
let self = this
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
isSelected: function (id) {
|
||||
return mailbox.selected.indexOf(id) != -1
|
||||
},
|
||||
|
||||
// mark selected messages as unread
|
||||
markSelectedUnread: function () {
|
||||
let self = this
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
// universal handler to delete current or selected messages
|
||||
deleteMessages: function () {
|
||||
let ids = []
|
||||
let self = this
|
||||
ids = JSON.parse(JSON.stringify(mailbox.selected))
|
||||
if (!ids.length) {
|
||||
return false
|
||||
}
|
||||
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
},
|
||||
|
||||
// test if any selected emails are unread
|
||||
selectedHasUnread: function () {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
// test of any selected emails are read
|
||||
selectedHasRead: function () {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
for (let i in mailbox.messages) {
|
||||
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
|
||||
v-on:click="markSelectedRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark read
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
|
||||
v-on:click="markSelectedUnread">
|
||||
<i class="bi bi-eye-slash me-1"></i>
|
||||
Mark unread
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete selected
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancel selection
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
104
server/ui-src/components/NavTags.vue
Normal file
104
server/ui-src/components/NavTags.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// if the current filter is active then reload view
|
||||
reloadFilter: function (tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
if (!query) {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`^tag:"?${tag}"?$`, 'i')
|
||||
if (query.match(re)) {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
}
|
||||
},
|
||||
|
||||
// test whether a tag is currently being searched for (in the URL)
|
||||
inSearch: function (tag) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const query = urlParams.get('q')
|
||||
if (!query) {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
|
||||
return query.match(re)
|
||||
},
|
||||
|
||||
// toggle a tag search in the search URL, add or remove it accordingly
|
||||
toggleTag: function (e, tag) {
|
||||
e.preventDefault()
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let query = urlParams.get('q') ? urlParams.get('q') : ''
|
||||
|
||||
let re = new RegExp(`(^|\\s)((-|\\!)?tag:"?${tag}"?)($|\\s)`, 'i')
|
||||
|
||||
if (query.match(re)) {
|
||||
// remove is exists
|
||||
query = query.replace(re, '$1$4')
|
||||
} else {
|
||||
// add to query
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
}
|
||||
query = query + " tag:" + tag
|
||||
}
|
||||
|
||||
query = query.trim()
|
||||
|
||||
if (query == '') {
|
||||
this.$router.push('/')
|
||||
} else {
|
||||
this.$router.push('/search?q=' + encodeURIComponent(query))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
|
||||
<template v-if="mailbox.showTagColors">Hide</template>
|
||||
<template v-else>Show</template>
|
||||
tag colors
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-5 pb-3">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
|
||||
v-on:click="reloadFilter(tag)" v-on:click.ctrl="toggleTag($event, tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
<i class="bi bi-tag" v-else></i>
|
||||
{{ tag }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
190
server/ui-src/components/Notifications.vue
Normal file
190
server/ui-src/components/Notifications.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { Toast } from 'bootstrap'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
toastMessage: false,
|
||||
reconnectRefresh: false,
|
||||
socketURI: false,
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let d = document.getElementById('app')
|
||||
if (d) {
|
||||
this.version = d.dataset.version
|
||||
}
|
||||
|
||||
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
|
||||
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
|
||||
|
||||
this.connect()
|
||||
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
},
|
||||
|
||||
methods: {
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let ws = new WebSocket(this.socketURI)
|
||||
let self = this
|
||||
ws.onmessage = function (e) {
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(e.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (!mailbox.searching) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(response.Data)
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
}
|
||||
}
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort()
|
||||
}
|
||||
}
|
||||
|
||||
// send notifications
|
||||
if (!self.pauseNotifications) {
|
||||
self.pauseNotifications = true
|
||||
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
|
||||
self.browserNotify("New mail from: " + from, response.Data.Subject)
|
||||
self.setMessageToast(response.Data)
|
||||
// delay notifications by 2s
|
||||
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
|
||||
}
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
mailbox.unread = response.Data.Unread
|
||||
|
||||
// detect version updated, refresh is needed
|
||||
if (self.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = function () {
|
||||
mailbox.connected = true
|
||||
if (self.reconnectRefresh) {
|
||||
self.reconnectRefresh = false
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = function (e) {
|
||||
mailbox.connected = false
|
||||
self.reconnectRefresh = true
|
||||
|
||||
setTimeout(function () {
|
||||
self.connect() // reconnect
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onerror = function (err) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
|
||||
browserNotify: function (title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let b = message.Subject
|
||||
let options = {
|
||||
body: message,
|
||||
icon: this.resolve('/notification.png')
|
||||
}
|
||||
new Notification(title, options)
|
||||
}
|
||||
},
|
||||
|
||||
setMessageToast: function (m) {
|
||||
// don't display if browser notifications are enabled, or a toast is already displayed
|
||||
if (mailbox.notificationsEnabled || this.toastMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
this.toastMessage = m
|
||||
|
||||
let self = this
|
||||
let el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
el.addEventListener('hidden.bs.toast', () => {
|
||||
self.toastMessage = false
|
||||
})
|
||||
|
||||
Toast.getOrCreateInstance(el).show()
|
||||
}
|
||||
},
|
||||
|
||||
closeToast: function () {
|
||||
let el = document.getElementById('messageToast')
|
||||
if (el) {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
<strong class="me-auto">
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="toast-body">
|
||||
<div>
|
||||
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
|
||||
@click="closeToast">
|
||||
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
|
||||
<template v-else>
|
||||
[ no subject ]
|
||||
</template>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
92
server/ui-src/components/Pagination.vue
Normal file
92
server/ui-src/components/Pagination.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
|
||||
export default {
|
||||
|
||||
mixins: [CommonMixins],
|
||||
|
||||
props: {
|
||||
total: Number,
|
||||
},
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
mailbox,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canPrev: function () {
|
||||
return pagination.start > 0
|
||||
},
|
||||
|
||||
canNext: function () {
|
||||
return this.total > (pagination.start + mailbox.messages.length)
|
||||
},
|
||||
|
||||
// returns the number of next X messages
|
||||
nextMessages: function () {
|
||||
let t = pagination.start + parseInt(pagination.limit, 10)
|
||||
if (t > this.total) {
|
||||
t = this.total
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeLimit: function () {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
viewNext: function () {
|
||||
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
|
||||
viewPrev: function () {
|
||||
let s = pagination.start - pagination.limit
|
||||
if (s < 0) {
|
||||
s = 0
|
||||
}
|
||||
pagination.start = s
|
||||
this.$emit('loadMessages')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
|
||||
:disabled="total == 0">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
|
||||
<small>
|
||||
<template v-if="total > 0">
|
||||
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
|
||||
<small>of</small>
|
||||
{{ formatNumber(total) }}
|
||||
</template>
|
||||
<span v-else class="text-muted">0 of 0</span>
|
||||
</small>
|
||||
|
||||
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
:title="'View previous ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
|
||||
:title="'View next ' + pagination.limit + ' messages'">
|
||||
<i class="bi bi-caret-right-fill"></i>
|
||||
</button>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user