mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-09 07:57:02 +00:00
Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe9195075 | ||
|
|
6a27e230a1 | ||
|
|
a805567810 | ||
|
|
83c70aa7c1 | ||
|
|
61241f11ac | ||
|
|
ebf4e6db5c | ||
|
|
da83ebbf47 | ||
|
|
b55fd26906 | ||
|
|
be3729c891 | ||
|
|
bdb1b9e053 | ||
|
|
e70cb75d4a | ||
|
|
5c1dfe5e26 | ||
|
|
73446ed6f7 | ||
|
|
528c35eec6 | ||
|
|
7071e7c188 | ||
|
|
fb2fe099b1 | ||
|
|
6879afb4a0 | ||
|
|
edc529fbde | ||
|
|
a324d817b3 | ||
|
|
053779c656 | ||
|
|
ddf2227397 | ||
|
|
bd892e3a48 | ||
|
|
b6454c902c | ||
|
|
25f8a47c73 | ||
|
|
28710d0462 | ||
|
|
cf18f529f4 | ||
|
|
c1b03212d5 | ||
|
|
026d676901 | ||
|
|
e660d6bedd | ||
|
|
d1d0ce4737 | ||
|
|
bdea197a0f | ||
|
|
9c9530081c | ||
|
|
ed8cac2454 | ||
|
|
3bbed37907 | ||
|
|
4fa8014735 | ||
|
|
23b1261cf9 | ||
|
|
85473762c5 | ||
|
|
f076d52603 | ||
|
|
cf93f99cc2 | ||
|
|
0f725ef1d8 | ||
|
|
0353520aeb | ||
|
|
bfd5837710 | ||
|
|
321bc338e6 | ||
|
|
75a6cfb31c | ||
|
|
7cb71ad5bf | ||
|
|
9892375366 | ||
|
|
e55d4aab59 | ||
|
|
d521eca2d1 | ||
|
|
e8c306b7ab | ||
|
|
f548bbb874 | ||
|
|
f067b76c58 | ||
|
|
5458b1044f | ||
|
|
294f9a21e6 | ||
|
|
26a2095674 | ||
|
|
b2a0d73572 | ||
|
|
400d5a36c1 | ||
|
|
9861bf96e1 | ||
|
|
e410fd42dc | ||
|
|
d049cb627f | ||
|
|
a70d9abdf2 | ||
|
|
d75efb8181 | ||
|
|
a856ce0cfa | ||
|
|
5d9aba726e | ||
|
|
667218b30b | ||
|
|
522733f537 | ||
|
|
848ce11a69 | ||
|
|
2d44159ecc | ||
|
|
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 |
34
.github/workflows/build-docker-edge.yml
vendored
Normal file
34
.github/workflows/build-docker-edge.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
name: Build docker edge images
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=edge-${{ github.sha }}"
|
||||
push: true
|
||||
tags: |
|
||||
axllent/mailpit:edge
|
||||
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v8.0.0
|
||||
- uses: actions/stale@v9.0.0
|
||||
with:
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# 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
|
||||
|
||||
2
.github/workflows/release-build.yml
vendored
2
.github/workflows/release-build.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- run: npm run package
|
||||
|
||||
# build the binaries
|
||||
- uses: wangyoucao577/go-release-action@v1.40
|
||||
- uses: wangyoucao577/go-release-action@v1.49
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
|
||||
15
.github/workflows/tests.yml
vendored
15
.github/workflows/tests.yml
vendored
@@ -9,15 +9,16 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.21.x]
|
||||
os: [ubuntu-latest]
|
||||
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 }}
|
||||
cache: false
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Go tests
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -30,15 +31,19 @@ jobs:
|
||||
|
||||
# build the assets
|
||||
- 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
|
||||
|
||||
270
CHANGELOG.md
270
CHANGELOG.md
@@ -2,6 +2,276 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.15.1]
|
||||
|
||||
### Chore
|
||||
- Code cleanup, remove redundant functionality
|
||||
- Add labels to Docker image ([#267](https://github.com/axllent/mailpit/issues/267))
|
||||
|
||||
### Feature
|
||||
- Add readyz subcommand for Docker healthcheck ([#270](https://github.com/axllent/mailpit/issues/270))
|
||||
|
||||
|
||||
## [v1.15.0]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Feature
|
||||
- Add SMTP TLS option ([#265](https://github.com/axllent/mailpit/issues/265))
|
||||
|
||||
### Fix
|
||||
- Enforce SMTP STARTTLS by default if authentication is set
|
||||
|
||||
|
||||
## [v1.14.4]
|
||||
|
||||
### Chore
|
||||
- Update caniemail test data
|
||||
- Reorder CLI flags to group by related functionality
|
||||
|
||||
### Feature
|
||||
- Allow setting SMTP relay configuration values via environment variables ([#262](https://github.com/axllent/mailpit/issues/262))
|
||||
|
||||
|
||||
## [v1.14.3]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Fix
|
||||
- Prevent crash when calculating deleted space percentage (divide by zero)
|
||||
|
||||
|
||||
## [v1.14.2]
|
||||
|
||||
### Chore
|
||||
- Allow setting of multiple message tags via plus addresses ([#253](https://github.com/axllent/mailpit/issues/253))
|
||||
|
||||
### Fix
|
||||
- Prevent runtime error when calculating total messages size of empty table ([#263](https://github.com/axllent/mailpit/issues/263))
|
||||
|
||||
|
||||
## [v1.14.1]
|
||||
|
||||
### Chore
|
||||
- Tag names now allow `.` and must be a minimum of 1 character
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Feature
|
||||
- Option to enforce TitleCasing for all newly created tags
|
||||
- Set message tags using plus addressing ([#253](https://github.com/axllent/mailpit/issues/253))
|
||||
|
||||
### Fix
|
||||
- Handle null values in Mailpit settings, set DeletedSize=0 if null
|
||||
|
||||
|
||||
## [v1.14.0]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Refactor storage library
|
||||
- Security improvements (gosec)
|
||||
- Switch to short uuid format for database IDs
|
||||
- Better handling of automatic database compression (vacuuming) after deleting messages
|
||||
|
||||
### Docker
|
||||
- Add edge Docker images for latest unreleased features
|
||||
|
||||
### Feature
|
||||
- Optional POP3 server ([#249](https://github.com/axllent/mailpit/issues/249))
|
||||
|
||||
|
||||
## [v1.13.3]
|
||||
|
||||
### API
|
||||
- Include Reply-To information in message summaries for message list & websocket events
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Compress database only when >= 1% of total message size has been deleted
|
||||
- Update "About" modal layout when new version is available
|
||||
|
||||
### Feature
|
||||
- Add reply-to:<search> search filter ([#247](https://github.com/axllent/mailpit/issues/247))
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -12,10 +12,19 @@ CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Vers
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
LABEL org.opencontainers.image.title="Mailpit" \
|
||||
org.opencontainers.image.description="An email and SMTP testing tool with API for developers" \
|
||||
org.opencontainers.image.source="https://github.com/axllent/mailpit" \
|
||||
org.opencontainers.image.url="https://mailpit.axllent.org" \
|
||||
org.opencontainers.image.documentation="https://mailpit.axllent.org/docs/" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
COPY --from=builder /mailpit /mailpit
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 8025/tcp
|
||||
EXPOSE 1025/tcp 1110/tcp 8025/tcp
|
||||
|
||||
HEALTHCHECK --interval=15s CMD /mailpit readyz
|
||||
|
||||
ENTRYPOINT ["/mailpit"]
|
||||
|
||||
65
README.md
65
README.md
@@ -1,44 +1,55 @@
|
||||
# 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 small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
|
||||
<hr>
|
||||
|
||||
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and contains an API for automated integration testing.
|
||||
**Mailpit** is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
|
||||
|
||||
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 for a few years now.
|
||||
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 [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
|
||||
- Runs entirely from a single [static binary](https://mailpit.axllent.org/docs/install/) or multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)
|
||||
- Modern web UI with advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/) 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/http/) & [authentication](https://mailpit.axllent.org/docs/configuration/http/)
|
||||
- [SMTP server](https://mailpit.axllent.org/docs/configuration/smtp/) with optional STARTTLS or SSL/TLS, authentication (including an "accept any" mode)
|
||||
- A [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing
|
||||
- Real-time web UI updates using web sockets for new mail & optional [browser notifications](https://mailpit.axllent.org/docs/usage/notifications/) when new mail is received
|
||||
- Optional [POP3 server](https://mailpit.axllent.org/docs/configuration/pop3/) to download captured message directly into your email client
|
||||
- [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
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- 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)
|
||||
- A simple [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing
|
||||
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
|
||||
- [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 message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
|
||||
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
- Multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
151
cmd/ingest.go
Normal file
151
cmd/ingest.go
Normal file
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
|
||||
if ingestRecent > 0 && time.Since(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
|
||||
}
|
||||
75
cmd/readyz.go
Normal file
75
cmd/readyz.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
useHTTPS bool
|
||||
)
|
||||
|
||||
// readyzCmd represents the healthcheck command
|
||||
var readyzCmd = &cobra.Command{
|
||||
Use: "readyz",
|
||||
Short: "Run a healthcheck to test if Mailpit is running",
|
||||
Long: `This command connects to the /readyz endpoint of a running Mailpit server
|
||||
and exits with a status of 0 if the connection is successful, else with a
|
||||
status 1 if unhealthy.
|
||||
|
||||
If running within Docker, it should automatically detect environment
|
||||
settings to determine the HTTP bind interface & port.
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
|
||||
proto := "http"
|
||||
if useHTTPS {
|
||||
proto = "https"
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
|
||||
|
||||
conf := &http.Transport{
|
||||
IdleConnTimeout: time.Second * 5,
|
||||
ExpectContinueTimeout: time.Second * 5,
|
||||
TLSHandshakeTimeout: time.Second * 5,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: conf}
|
||||
|
||||
res, err := client.Get(uri)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(readyzCmd)
|
||||
|
||||
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
|
||||
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
|
||||
}
|
||||
|
||||
if len(os.Getenv("MP_WEBROOT")) > 0 {
|
||||
config.Webroot = os.Getenv("MP_WEBROOT")
|
||||
}
|
||||
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
|
||||
if config.UITLSCert != "" {
|
||||
useHTTPS = true
|
||||
}
|
||||
|
||||
readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port")
|
||||
readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)")
|
||||
}
|
||||
221
cmd/root.go
221
cmd/root.go
@@ -2,7 +2,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -11,14 +10,13 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "mailpit",
|
||||
@@ -79,43 +77,62 @@ func init() {
|
||||
// load and warn deprecated ENV vars
|
||||
initDeprecatedConfigFromEnv()
|
||||
|
||||
// load ENV vars
|
||||
// load environment variables
|
||||
initConfigFromEnv()
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
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(&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")
|
||||
|
||||
// Web UI / API
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface & port for UI")
|
||||
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & 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")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
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().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
||||
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.SMTPRequireSTARTTLS, "smtp-require-starttls", config.SMTPRequireSTARTTLS, "Require SMTP client use STARTTLS")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRequireTLS, "smtp-require-tls", config.SMTPRequireTLS, "Require client use SSL/TLS")
|
||||
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")
|
||||
|
||||
// SMTP relay
|
||||
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!)")
|
||||
|
||||
// POP3 server
|
||||
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
|
||||
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
|
||||
rootCmd.Flags().StringVar(&config.POP3TLSCert, "pop3-tls-cert", config.POP3TLSCert, "Optional TLS certificate for POP3 server - requires pop3-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")
|
||||
|
||||
// Tagging
|
||||
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "Convert new tags automatically to TitleCase")
|
||||
|
||||
// Webhook
|
||||
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().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
|
||||
// deprecated flags 2023/03/12
|
||||
// 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")
|
||||
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
|
||||
@@ -128,51 +145,138 @@ func init() {
|
||||
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
|
||||
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
|
||||
|
||||
// DEPRECATED FLAGS 2024/03/16
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-tls-required", config.SMTPRequireSTARTTLS, "smtp-require-starttls")
|
||||
rootCmd.Flags().Lookup("smtp-tls-required").Hidden = true
|
||||
rootCmd.Flags().Lookup("smtp-tls-required").Deprecated = "use --smtp-require-starttls"
|
||||
}
|
||||
|
||||
// Load settings from environment
|
||||
func initConfigFromEnv() {
|
||||
// inherit from environment if provided
|
||||
// General
|
||||
config.DataFile = os.Getenv("MP_DATA_FILE")
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
|
||||
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
|
||||
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
|
||||
}
|
||||
if len(os.Getenv("MP_TAG")) > 0 {
|
||||
config.SMTPCLITags = os.Getenv("MP_TAG")
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
|
||||
config.IgnoreDuplicateIDs = true
|
||||
}
|
||||
if len(os.Getenv("MP_LOG_FILE")) > 0 {
|
||||
logger.LogFile = os.Getenv("MP_LOG_FILE")
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_VERBOSE") {
|
||||
logger.VerboseLogging = true
|
||||
}
|
||||
|
||||
// UI
|
||||
// Web UI & API
|
||||
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
|
||||
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_WEBROOT")) > 0 {
|
||||
config.Webroot = os.Getenv("MP_WEBROOT")
|
||||
}
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
auth.SetUIAuth(os.Getenv("MP_UI_AUTH"))
|
||||
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
}
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
if len(os.Getenv("MP_API_CORS")) > 0 {
|
||||
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// SMTP
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
}
|
||||
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 err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
}
|
||||
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
|
||||
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
|
||||
if getEnabledFromEnv("MP_SMTP_REQUIRE_STARTTLS") {
|
||||
config.SMTPRequireSTARTTLS = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") {
|
||||
config.SMTPRequireTLS = true
|
||||
}
|
||||
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
|
||||
config.SMTPAuthAllowInsecure = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
|
||||
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
|
||||
// SMTP relay
|
||||
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
|
||||
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
|
||||
config.SMTPRelayAllIncoming = true
|
||||
}
|
||||
config.SMTPRelayConfig = config.SMTPRelayConfigStruct{}
|
||||
config.SMTPRelayConfig.Host = os.Getenv("MP_SMTP_RELAY_HOST")
|
||||
if len(os.Getenv("MP_SMTP_RELAY_PORT")) > 0 {
|
||||
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
|
||||
}
|
||||
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
|
||||
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
|
||||
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
|
||||
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
|
||||
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
|
||||
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
|
||||
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
|
||||
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
|
||||
|
||||
// POP3 server
|
||||
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
|
||||
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")
|
||||
}
|
||||
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
|
||||
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
}
|
||||
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
|
||||
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
|
||||
|
||||
// Tagging
|
||||
if len(os.Getenv("MP_TAG")) > 0 {
|
||||
config.SMTPCLITags = os.Getenv("MP_TAG")
|
||||
}
|
||||
if getEnabledFromEnv("MP_TAGS_TITLE_CASE") {
|
||||
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
@@ -181,59 +285,40 @@ func initConfigFromEnv() {
|
||||
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")
|
||||
}
|
||||
if len(os.Getenv("MP_API_CORS")) > 0 {
|
||||
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
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 getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||
config.AllowUntrustedTLS = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_VERBOSE") {
|
||||
logger.VerboseLogging = true
|
||||
}
|
||||
}
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// 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
|
||||
}
|
||||
// deprecated 2024/03.16
|
||||
if getEnabledFromEnv("MP_SMTP_TLS_REQUIRED") {
|
||||
logger.Log().Warn("ENV MP_SMTP_TLS_REQUIRED has been deprecated, use MP_SMTP_REQUIRE_STARTTLS")
|
||||
config.SMTPRequireSTARTTLS = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to get a boolean from an environment variable
|
||||
|
||||
248
config/config.go
248
config/config.go
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -51,6 +53,15 @@ var (
|
||||
// SMTPTLSKey file
|
||||
SMTPTLSKey string
|
||||
|
||||
// SMTPRequireSTARTTLS to enforce the use of STARTTLS
|
||||
// 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.
|
||||
SMTPRequireSTARTTLS bool
|
||||
|
||||
// SMTPRequireTLS to allow only SSL/TLS connections for all connections
|
||||
//
|
||||
SMTPRequireTLS bool
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
|
||||
@@ -60,6 +71,11 @@ var (
|
||||
// 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
|
||||
|
||||
@@ -73,7 +89,7 @@ var (
|
||||
SMTPCLITags string
|
||||
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
|
||||
|
||||
// SMTPTags are expressions to apply tags to new mail
|
||||
SMTPTags []AutoTag
|
||||
@@ -82,12 +98,18 @@ var (
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
SMTPRelayConfig smtpRelayConfigStruct
|
||||
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
|
||||
|
||||
@@ -95,6 +117,21 @@ var (
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
|
||||
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
|
||||
POP3Listen = "[::]:1110"
|
||||
|
||||
// POP3AuthFile for POP3 authentication
|
||||
POP3AuthFile string
|
||||
|
||||
// POP3TLSCert TLS certificate
|
||||
POP3TLSCert string
|
||||
|
||||
// POP3TLSKey TLS certificate key
|
||||
POP3TLSKey string
|
||||
|
||||
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||
EnableSpamAssassin string
|
||||
|
||||
// WebhookURL for calling
|
||||
WebhookURL string
|
||||
|
||||
@@ -121,18 +158,20 @@ type AutoTag struct {
|
||||
}
|
||||
|
||||
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
|
||||
type smtpRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
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"` // allow overriding the bounce address
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
|
||||
RecipientAllowlistRegexp *regexp.Regexp
|
||||
type SMTPRelayConfigStruct struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
STARTTLS bool `yaml:"starttls"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
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"` // allow overriding the bounce address
|
||||
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
// DEPRECATED 2024/03/12
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
@@ -150,58 +189,80 @@ func VerifyConfig() error {
|
||||
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 != "" {
|
||||
UIAuthFile = filepath.Clean(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)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(UIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
|
||||
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 != "" {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
|
||||
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 SMTPRequireTLS {
|
||||
return errors.New("[smtp] TLS cannot be required without an SMTP TLS certificate and key")
|
||||
} else if SMTPRequireSTARTTLS {
|
||||
return errors.New("[smtp] STARTTLS cannot be required without an SMTP TLS certificate and key")
|
||||
}
|
||||
if SMTPRequireSTARTTLS && SMTPAuthAllowInsecure || SMTPRequireTLS && SMTPAuthAllowInsecure {
|
||||
return errors.New("[smtp] TLS cannot be required with --smtp-auth-allow-insecure")
|
||||
}
|
||||
if SMTPRequireSTARTTLS && SMTPRequireTLS {
|
||||
return errors.New("[smtp] TLS & STARTTLS cannot be required together")
|
||||
}
|
||||
|
||||
if SMTPAuthFile != "" {
|
||||
SMTPAuthFile = filepath.Clean(SMTPAuthFile)
|
||||
|
||||
if !isFile(SMTPAuthFile) {
|
||||
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
|
||||
return fmt.Errorf("[smtp] password file not found: %s", SMTPAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SMTPAuthFile)
|
||||
@@ -212,26 +273,87 @@ func VerifyConfig() error {
|
||||
if err := auth.SetSMTPAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !SMTPAuthAllowInsecure {
|
||||
// https://www.rfc-editor.org/rfc/rfc4954
|
||||
// A server implementation MUST implement a configuration in which
|
||||
// it does NOT permit any plaintext password mechanisms, unless either
|
||||
// the STARTTLS [SMTP-TLS] command has been negotiated or some other
|
||||
// mechanism that protects the session from password snooping has been
|
||||
// provided. Server sites SHOULD NOT use any configuration which
|
||||
// permits a plaintext password mechanism without such a protection
|
||||
// mechanism against password snooping.
|
||||
SMTPRequireSTARTTLS = true
|
||||
}
|
||||
}
|
||||
|
||||
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
|
||||
return errors.New("SMTP authentication cannot use both credentials and --smtp-auth-accept-any")
|
||||
return errors.New("[smtp] authentication cannot use both credentials and --smtp-auth-accept-any")
|
||||
}
|
||||
|
||||
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")
|
||||
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.]`)
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
|
||||
if !isFile(POP3TLSCert) {
|
||||
return fmt.Errorf("[pop3] TLS certificate not found: %s", POP3TLSCert)
|
||||
}
|
||||
|
||||
if !isFile(POP3TLSKey) {
|
||||
return fmt.Errorf("[pop3] TLS key not found: %s", POP3TLSKey)
|
||||
}
|
||||
}
|
||||
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
|
||||
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
|
||||
}
|
||||
if POP3Listen != "" {
|
||||
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[pop3] %s", err.Error())
|
||||
}
|
||||
}
|
||||
if POP3AuthFile != "" {
|
||||
POP3AuthFile = filepath.Clean(POP3AuthFile)
|
||||
|
||||
if !isFile(POP3AuthFile) {
|
||||
return fmt.Errorf("[pop3] password file not found: %s", POP3AuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(POP3AuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetPOP3Auth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Web root
|
||||
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)
|
||||
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{}
|
||||
@@ -244,25 +366,40 @@ func VerifyConfig() error {
|
||||
if len(t) > 1 {
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
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
|
||||
}
|
||||
|
||||
// separate relay config validation to account for environment variables
|
||||
if err := validateRelayConfig(); 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 {
|
||||
@@ -273,14 +410,16 @@ func VerifyConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse & validate the SMTPRelayConfigFile (if set)
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
|
||||
return fmt.Errorf("[smtp] relay configuration not found: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
@@ -293,7 +432,24 @@ func parseRelayConfig(c string) error {
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("SMTP relay host not set")
|
||||
return errors.New("[smtp] relay host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed_recipients' instead")
|
||||
if SMTPRelayConfig.AllowedRecipients == "" {
|
||||
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPRelayConfig (if Host is set)
|
||||
func validateRelayConfig() error {
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Port == 0 {
|
||||
@@ -306,35 +462,35 @@ 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")
|
||||
}
|
||||
} 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)
|
||||
return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
} 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
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
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)
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = allowlistRegexp
|
||||
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"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",
|
||||
"Tags": ["test"],
|
||||
"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
|
||||
|
||||
- `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,169 +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,
|
||||
"messages_count": 50,
|
||||
"start": 0,
|
||||
"tags": ["test"],
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"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",
|
||||
"Tags": ["test"],
|
||||
"Size": 6144,
|
||||
"Attachments": 0
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- `total` - Total messages in mailbox
|
||||
- `unread` - Total unread messages in mailbox
|
||||
- `messages_count` - Total number of messages in mailbox
|
||||
- `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 https://mailpit.axllent.org/docs/api-v1/.
|
||||
|
||||
The API is split into four 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,70 +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,
|
||||
"messages_count": 25,
|
||||
"start": 0,
|
||||
"messages": [
|
||||
{
|
||||
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
|
||||
"MessageID": "12345.67890@localhost",
|
||||
"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)
|
||||
- `messages_count` - Total number of messages matching search
|
||||
- `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`
|
||||
@@ -17,6 +17,7 @@ const ctx = await esbuild.context(
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
|
||||
41
go.mod
41
go.mod
@@ -4,28 +4,28 @@ go 1.20
|
||||
|
||||
require (
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jhillyerd/enmime v1.0.1
|
||||
github.com/klauspost/compress v1.17.2
|
||||
github.com/jhillyerd/enmime v1.2.0
|
||||
github.com/klauspost/compress v1.17.7
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/lithammer/shortuuid/v4 v4.0.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
|
||||
github.com/tg123/go-htpasswd v1.2.2
|
||||
github.com/vanng822/go-premailer v1.20.2
|
||||
golang.org/x/net v0.18.0
|
||||
golang.org/x/net v0.22.0
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/time v0.4.0
|
||||
golang.org/x/time v0.5.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.27.0
|
||||
modernc.org/sqlite v1.29.5
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,34 +36,31 @@ require (
|
||||
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.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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.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.7 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.15.0 // indirect
|
||||
golang.org/x/image v0.14.0 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/tools v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.32.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||
modernc.org/libc v1.45.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.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
102
go.sum
102
go.sum
@@ -5,10 +5,9 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1e
|
||||
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/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
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=
|
||||
@@ -51,12 +50,12 @@ 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-20230922112808-5421fefb8386 h1:EcQR3gusLHN46TAD+G+EbaaqJArt5vHhNpXAa12PQf4=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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=
|
||||
@@ -64,16 +63,16 @@ 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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 v1.0.1 h1:y6RyqIgBOI2hIinOXIzmeB+ITRVls0zTJIm5GwgXnjE=
|
||||
github.com/jhillyerd/enmime v1.0.1/go.mod h1:LMMbm6oTlzWHghPavqHtOrP/NosVv3l42CUrZjn03/Q=
|
||||
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.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
|
||||
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
|
||||
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
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=
|
||||
@@ -83,15 +82,19 @@ 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/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
|
||||
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.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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
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=
|
||||
@@ -105,8 +108,8 @@ github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvE
|
||||
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.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/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=
|
||||
@@ -127,8 +130,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
|
||||
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
|
||||
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=
|
||||
@@ -141,37 +144,32 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
||||
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.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
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.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
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.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.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
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.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
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=
|
||||
@@ -179,28 +177,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.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/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.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
|
||||
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
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-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.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
|
||||
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
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=
|
||||
@@ -210,27 +206,23 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.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.32.0 h1:yXatHTrACp3WaKNRCoZwUK7qj5V8ep1XyY0ka4oYcNc=
|
||||
modernc.org/libc v1.32.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/cc/v4 v4.19.3 h1:vE9kmJqUcyvNOf8F2Hn8od14SOMq34BiqcZ2tMzLk5c=
|
||||
modernc.org/ccgo/v4 v4.11.0 h1:2uc2kRvZLC/oHylsrirRW6f1I4wljQST2BBbm+aKiXM=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.45.0 h1:qmAJZf9tYFqK/SFSFqpBc9uHWGsvoYWtRcMQdG+JEfM=
|
||||
modernc.org/libc v1.45.0/go.mod h1:YkRHLoN4L70OdO1cVmM83KZhRbRvsc3XogfVzbTXBwE=
|
||||
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.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
|
||||
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
|
||||
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
|
||||
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=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
|
||||
@@ -13,6 +13,8 @@ var (
|
||||
UICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
// POP3Credentials passwords
|
||||
POP3Credentials *htpasswd.File
|
||||
)
|
||||
|
||||
// SetUIAuth will set Basic Auth credentials required for the UI & API
|
||||
@@ -53,6 +55,25 @@ func SetSMTPAuth(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPOP3Auth will set POP3 server credentials
|
||||
func SetPOP3Auth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
POP3Credentials, 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+`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ var htmlTests = map[string]string{
|
||||
// HTML tests
|
||||
"html-object": "object, embed, image, pdf",
|
||||
"html-link": "link",
|
||||
"html-hr": "hr",
|
||||
"html-dialog": "dialog",
|
||||
"html-srcset": "[srcset]",
|
||||
"html-picture": "picture",
|
||||
|
||||
@@ -25,14 +25,12 @@ func runCSSTests(html string) ([]Warning, int, error) {
|
||||
|
||||
inlined, err := inlineRemoteCSS(html)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
inlined = html
|
||||
}
|
||||
|
||||
// merge all CSS inline
|
||||
merged, err := mergeInlineCSS(inlined)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
merged = inlined
|
||||
}
|
||||
|
||||
@@ -157,7 +155,7 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warningf("html check failed to download %s", a.Val)
|
||||
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -179,7 +177,7 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
|
||||
newDoc, err := doc.Html()
|
||||
if err != nil {
|
||||
logger.Log().Warning(err)
|
||||
logger.Log().Warnf("[html-check] failed to download %s", err.Error())
|
||||
return h, err
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,7 @@ func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
func extractTextLinks(msg *storage.Message) []string {
|
||||
links := []string{}
|
||||
|
||||
for _, match := range linkRe.FindAllString(msg.Text, -1) {
|
||||
links = append(links, match)
|
||||
}
|
||||
links = append(links, linkRe.FindAllString(msg.Text, -1)...)
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func doHead(link string, followRedirects bool) (int, error) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
@@ -79,7 +79,7 @@ func doHead(link string, followRedirects bool) (int, error) {
|
||||
|
||||
req, err := http.NewRequest("HEAD", link, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[link-check] %s", err.Error())
|
||||
return 0, err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -18,6 +19,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 +39,21 @@ func Log() *logrus.Logger {
|
||||
log.SetLevel(logrus.PanicLevel)
|
||||
}
|
||||
|
||||
log.Out = os.Stdout
|
||||
if LogFile != "" {
|
||||
file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec
|
||||
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()
|
||||
}
|
||||
166
internal/storage/cron.go
Normal file
166
internal/storage/cron.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// Database cron runs every minute
|
||||
func dbCron() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
|
||||
currentTime := time.Now()
|
||||
sinceLastDbAction := currentTime.Sub(dbLastAction)
|
||||
|
||||
// only run the database has been idle for 5 minutes
|
||||
if math.Floor(sinceLastDbAction.Minutes()) == 5 {
|
||||
deletedSize := getDeletedSize()
|
||||
|
||||
if deletedSize > 0 {
|
||||
total := totalMessagesSize()
|
||||
var deletedPercent int64
|
||||
if total == 0 {
|
||||
deletedPercent = 100
|
||||
} else {
|
||||
deletedPercent = deletedSize * 100 / total
|
||||
}
|
||||
// only vacuum the DB if at least 1% of mail storage size has been deleted
|
||||
if deletedPercent >= 1 {
|
||||
logger.Log().Debugf("[db] deleted messages is %d%% of total size, reclaim space", deletedPercent)
|
||||
vacuumDb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pruneMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
|
||||
// Set config.MaxMessages to 0 to disable.
|
||||
func pruneMessages() {
|
||||
if config.MaxMessages < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
q := sqlf.Select("ID, Size").
|
||||
From("mailbox").
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size int
|
||||
if err := q.Query(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, 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())
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
if err := tx.Rollback(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
addDeletedSize(prunedSize)
|
||||
dbLastAction = time.Now()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
|
||||
|
||||
logMessagesDeleted(len(ids))
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
|
||||
// Vacuum the database to reclaim space from deleted messages
|
||||
func vacuumDb() {
|
||||
start := time.Now()
|
||||
|
||||
// set WAL file checkpoint
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// vacuum database
|
||||
if _, err := db.Exec("VACUUM"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// truncate WAL file
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := SettingPut("DeletedSize", "0"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] vacuumed database in %s", elapsed)
|
||||
}
|
||||
@@ -2,29 +2,18 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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/google/uuid"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/leporo/sqlf"
|
||||
|
||||
@@ -33,12 +22,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
dbLastAction time.Time
|
||||
dbIsIdle bool
|
||||
dbDataDeleted bool
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
dbLastAction time.Time
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
@@ -76,6 +63,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
|
||||
@@ -110,7 +103,7 @@ func InitDB() error {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,565 +115,9 @@ func Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
}
|
||||
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
if config.UseMessageDates {
|
||||
if mDate, err := env.Date(); err == nil {
|
||||
created = mDate
|
||||
}
|
||||
}
|
||||
|
||||
// generate the search text
|
||||
searchText := createSearchText(env)
|
||||
|
||||
// generate unique ID
|
||||
id := uuid.New().String()
|
||||
|
||||
summaryJSON, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// extract tags from body matches based on --tag
|
||||
tagStr := findTagsInRawMessage(&body)
|
||||
|
||||
// extract tags from X-Tags header
|
||||
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
if headerTags != "" {
|
||||
tagStr += "," + headerTags
|
||||
}
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
tagJSON, err := json.Marshal(tagData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are stored successfully
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
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, Snippet) values(?,?,?,?,?,?,?,?,?,?,0, ?)",
|
||||
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON), snippet)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert compressed raw message
|
||||
compressed := dbEncoder.EncodeAll(body, make([]byte, 0, len(body)))
|
||||
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
c := &MessageSummary{}
|
||||
if err := json.Unmarshal(summaryJSON, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Snippet`).
|
||||
OrderBy("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, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet); 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.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
|
||||
results = append(results, em)
|
||||
}); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a Message generated from the mailbox_data collection.
|
||||
// If the message lacks a date header, then the received datetime is used.
|
||||
func GetMessage(id string) (*Message, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var from *mail.Address
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
|
||||
if returnPath == "" && from != nil {
|
||||
returnPath = from.Address
|
||||
}
|
||||
|
||||
date, err := env.Date()
|
||||
if err != nil {
|
||||
// return received datetime when message does not contain a date header
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created`).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(created)
|
||||
}); err != nil {
|
||||
logger.Log().Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
obj := Message{
|
||||
ID: id,
|
||||
MessageID: messageID,
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: len(raw),
|
||||
Text: env.Text,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
|
||||
for _, i := range env.Inlines {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range env.OtherParts {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.FileName != "" || a.ContentID != "" {
|
||||
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
|
||||
}
|
||||
}
|
||||
|
||||
// mark message as read
|
||||
if err := MarkRead(id); err != nil {
|
||||
return &obj, err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(id string) ([]byte, error) {
|
||||
var i string
|
||||
var msg string
|
||||
q := sqlf.From("mailbox_data").
|
||||
Select(`ID`).To(&i).
|
||||
Select(`Email`).To(&msg).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i == "" {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, a := range env.Inlines {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.OtherParts {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil, errors.New("attachment not found")
|
||||
}
|
||||
|
||||
// MarkRead will mark a message as read
|
||||
func MarkRead(id string) error {
|
||||
if !IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkAllRead will mark all messages as read
|
||||
func MarkAllRead() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountUnread()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("Read = ?", 0).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllUnread will mark all messages as unread
|
||||
func MarkAllUnread() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountRead()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 0).
|
||||
Where("Read = ?", 1).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteOneMessage will delete a single message from a mailbox
|
||||
func DeleteOneMessage(id string) error {
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted message %s", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total int
|
||||
)
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(nil, db)
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// summaries and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("VACUUM")
|
||||
if err == nil {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = false
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllTags returns all used tags
|
||||
func GetAllTags() []string {
|
||||
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 tags
|
||||
// Ping the database connection and return an error if unsuccessful
|
||||
func Ping() error {
|
||||
return db.Ping()
|
||||
}
|
||||
|
||||
// StatsGet returns the total/unread statistics for a mailbox
|
||||
@@ -706,7 +143,7 @@ func CountTotal() int {
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(nil, db)
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
}
|
||||
@@ -715,11 +152,10 @@ func CountTotal() int {
|
||||
func CountUnread() int {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
}
|
||||
@@ -728,26 +164,23 @@ func CountUnread() int {
|
||||
func CountRead() int {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("Read = ?", 1).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// IsUnread returns the number of emails in the database that are unread.
|
||||
// If an ID is supplied, then it is just limited to that message.
|
||||
// IsUnread returns whether a message is unread or not.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&unread).
|
||||
Where("Read = ?", 0).
|
||||
Where("ID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("ID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return unread == 1
|
||||
}
|
||||
@@ -756,11 +189,10 @@ func IsUnread(id string) bool {
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("MessageID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("MessageID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total != 0
|
||||
}
|
||||
|
||||
619
internal/storage/messages.go
Normal file
619
internal/storage/messages.go
Normal file
@@ -0,0 +1,619 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/jhillyerd/enmime"
|
||||
"github.com/leporo/sqlf"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// 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().Warnf("[message] %s", err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
if config.UseMessageDates {
|
||||
if mDate, err := env.Date(); err == nil {
|
||||
created = mDate
|
||||
}
|
||||
}
|
||||
|
||||
// generate the search text
|
||||
searchText := createSearchText(env)
|
||||
|
||||
// generate unique ID
|
||||
id := shortuuid.New()
|
||||
|
||||
summaryJSON, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// extract tags from body matches based on --tag, plus addresses & X-Tags header
|
||||
tagStr := findTagsInRawMessage(body) + "," +
|
||||
obj.tagsFromPlusAddresses() + "," +
|
||||
strings.TrimSpace(env.Root.Header.Get("X-Tags"))
|
||||
|
||||
tagData := uniqueTagsFromString(tagStr)
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are stored successfully
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
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, 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, size))
|
||||
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
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(context.TODO(), 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 read int
|
||||
var snippet string
|
||||
em := MessageSummary{}
|
||||
|
||||
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().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
|
||||
// artificially generate ReplyTo if legacy data is missing Reply-To field
|
||||
if em.ReplyTo == nil {
|
||||
em.ReplyTo = []*mail.Address{}
|
||||
}
|
||||
|
||||
results = append(results, 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()
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] list INBOX in %s", elapsed)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMessage returns a Message generated from the mailbox_data collection.
|
||||
// If the message lacks a date header, then the received datetime is used.
|
||||
func GetMessage(id string) (*Message, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var from *mail.Address
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
|
||||
if returnPath == "" && from != nil {
|
||||
returnPath = from.Address
|
||||
}
|
||||
|
||||
date, err := env.Date()
|
||||
if err != nil {
|
||||
// return received datetime when message does not contain a date header
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created`).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(created)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
obj := Message{
|
||||
ID: id,
|
||||
MessageID: messageID,
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: len(raw),
|
||||
Text: env.Text,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
|
||||
for _, i := range env.Inlines {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range env.OtherParts {
|
||||
if i.FileName != "" || i.ContentID != "" {
|
||||
obj.Inline = append(obj.Inline, AttachmentSummary(i))
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.FileName != "" || a.ContentID != "" {
|
||||
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(id string) ([]byte, error) {
|
||||
var i string
|
||||
var msg string
|
||||
q := sqlf.From("mailbox_data").
|
||||
Select(`ID`).To(&i).
|
||||
Select(`Email`).To(&msg).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i == "" {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, a := range env.Inlines {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.OtherParts {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range env.Attachments {
|
||||
if a.PartID == partID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as read", id)
|
||||
}
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkAllRead will mark all messages as read
|
||||
func MarkAllRead() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountUnread()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 1).
|
||||
Where("Read = ?", 0).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllUnread will mark all messages as unread
|
||||
func MarkAllUnread() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total = CountRead()
|
||||
)
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 0).
|
||||
Where("Read = ?", 1).
|
||||
ExecAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkUnread will mark a message as unread
|
||||
func MarkUnread(id string) error {
|
||||
if IsUnread(id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sqlf.Update("mailbox").
|
||||
Set("Read", 0).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] marked message %s as unread", id)
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteOneMessage will delete a single message from a mailbox
|
||||
func DeleteOneMessage(id string) error {
|
||||
m, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
size := len(m)
|
||||
// begin a transaction to ensure both the message
|
||||
// and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted message %s", id)
|
||||
}
|
||||
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(size))
|
||||
|
||||
logMessagesDeleted(1)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total int
|
||||
)
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
// begin a transaction to ensure both the message
|
||||
// summaries and data are deleted successfully
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM mailbox_data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
|
||||
|
||||
vacuumDb()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
if err := SettingPut("DeletedSize", "0"); err != nil {
|
||||
logger.Log().Warnf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestRetrieveMimeEmail(t *testing.T) {
|
||||
|
||||
t.Log("Testing mime email retrieval")
|
||||
|
||||
id, err := Store(testMimeEmail)
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -123,7 +123,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
|
||||
t.Log("Testing message summary")
|
||||
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func BenchmarkImportMime(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/leporo/sqlf"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
func dataMigrations() {
|
||||
updateOrderByCreatedTask()
|
||||
assignMessageIDsTask()
|
||||
}
|
||||
|
||||
// Update Created column using Created metadata datetime <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func updateOrderByCreatedTask() {
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Select(`json_extract(Metadata, '$.Created') as Created`).
|
||||
Where("Created < ?", 1155000600)
|
||||
|
||||
toUpdate := make(map[string]int64)
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var ts sql.NullString
|
||||
if err := row.Scan(&id, &ts); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ts.Valid {
|
||||
logger.Log().Errorf("[migration] cannot get Created timestamp from %s", id)
|
||||
return
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339Nano, ts.String)
|
||||
toUpdate[id] = t.UnixMilli()
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
total := len(toUpdate)
|
||||
|
||||
if total == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] updating timestamp for %s messages", p.Sprintf("%d", len(toUpdate)))
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
var blockTime = time.Now()
|
||||
|
||||
count := 0
|
||||
for id, ts := range toUpdate {
|
||||
count++
|
||||
_, err := tx.Exec(`UPDATE mailbox SET Created = ? WHERE ID = ?`, ts, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] updated timestamp for 1,000 messages [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
|
||||
// Find any messages without a stored Message-ID and update it <= v1.6.5
|
||||
// Migration task implemented 05/2023 - can be removed end 2023
|
||||
func assignMessageIDsTask() {
|
||||
if !config.IgnoreDuplicateIDs {
|
||||
return
|
||||
}
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select("ID").
|
||||
Where("MessageID = ''")
|
||||
|
||||
missingIDS := make(map[string]string)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
if err := row.Scan(&id); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
missingIDS[id] = ""
|
||||
}); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
if len(missingIDS) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var count int
|
||||
var blockTime = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
total := len(missingIDS)
|
||||
|
||||
logger.Log().Infof("[migration] extracting Message-IDs for %s messages", p.Sprintf("%d", total))
|
||||
|
||||
for id := range missingIDS {
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
continue
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
|
||||
missingIDS[id] = messageID
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] extracted 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// begin a transaction
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
|
||||
count = 0
|
||||
|
||||
for id, mid := range missingIDS {
|
||||
_, err = tx.Exec(`UPDATE mailbox SET MessageID = ? WHERE ID = ?`, mid, id)
|
||||
if err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
if count%1000 == 0 {
|
||||
percent := (100 * count) / total
|
||||
logger.Log().Infof("[migration] stored 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
|
||||
blockTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error("[migration]", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[migration] complete")
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package storage
|
||||
|
||||
import "github.com/GuiaBolso/darwin"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/GuiaBolso/darwin"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
var (
|
||||
dbMigrations = []darwin.Migration{
|
||||
@@ -71,6 +79,35 @@ var (
|
||||
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);`,
|
||||
},
|
||||
{
|
||||
// assume deleted messages account for 50% of storage
|
||||
// to handle previously-deleted messages
|
||||
Version: 1.5,
|
||||
Description: "Create settings table",
|
||||
Script: `CREATE TABLE IF NOT EXISTS settings (
|
||||
Key TEXT,
|
||||
Value TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_key ON settings (Key);
|
||||
INSERT INTO settings (Key, Value) VALUES("DeletedSize", (SELECT SUM(Size)/2 FROM mailbox));`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -82,3 +119,71 @@ func dbApplyMigrations() error {
|
||||
|
||||
return d.Migrate()
|
||||
}
|
||||
|
||||
// These functions are used to migrate data formats/structure on startup.
|
||||
func dataMigrations() {
|
||||
// ensure DeletedSize has a value if empty
|
||||
if SettingGet("DeletedSize") == "" {
|
||||
_ = SettingPut("DeletedSize", "0")
|
||||
}
|
||||
|
||||
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(context.TODO(), 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(context.TODO(), 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(context.TODO(), db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
@@ -23,11 +24,13 @@ func BroadcastMailboxStats() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total int
|
||||
Unread int
|
||||
Total int
|
||||
Unread int
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
Unread: CountUnread(),
|
||||
Total: CountTotal(),
|
||||
Unread: CountUnread(),
|
||||
Version: config.Version,
|
||||
}
|
||||
|
||||
websockets.Broadcast("stats", b)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/mail"
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -24,12 +26,12 @@ func ReindexAll() {
|
||||
err := sqlf.Select("ID").To(&i).
|
||||
From("mailbox").
|
||||
OrderBy("Created DESC").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
ids = append(ids, i)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -37,14 +39,13 @@ func ReindexAll() {
|
||||
|
||||
chunks := chunkBy(ids, chunkSize)
|
||||
|
||||
logger.Log().Infof("Reindexing %d messages", total)
|
||||
|
||||
// fmt.Println(len(ids), " = ", len(chunks), "chunks")
|
||||
logger.Log().Infof("reindexing %d messages", total)
|
||||
|
||||
type updateStruct struct {
|
||||
ID string
|
||||
SearchText string
|
||||
Snippet string
|
||||
Metadata string
|
||||
}
|
||||
|
||||
for _, ids := range chunks {
|
||||
@@ -61,7 +62,29 @@ func ReindexAll() {
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
MetadataJSON, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -72,6 +95,7 @@ func ReindexAll() {
|
||||
u.ID = id
|
||||
u.SearchText = searchText
|
||||
u.Snippet = snippet
|
||||
u.Metadata = string(MetadataJSON)
|
||||
|
||||
updates = append(updates, u)
|
||||
}
|
||||
@@ -79,7 +103,7 @@ func ReindexAll() {
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -88,94 +112,24 @@ func ReindexAll() {
|
||||
|
||||
// insert mail summary data
|
||||
for _, u := range updates {
|
||||
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ? WHERE ID = ?", u.SearchText, u.Snippet, u.ID)
|
||||
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?", u.SearchText, u.Snippet, u.Metadata, u.ID)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
finished += len(updates)
|
||||
|
||||
logger.Log().Printf("Reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
|
||||
logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex will regenerate the search text and snippet for a message
|
||||
// and update the database.
|
||||
func Reindex(id string) error {
|
||||
// ids := []string{}
|
||||
// var i string
|
||||
// // chunkSize := 100
|
||||
|
||||
// err := sqlf.Select("ID").To(&i).From("mailbox_data").QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
// ids = append(ids, id)
|
||||
// })
|
||||
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// chunks := chunkBy(ids, 100)
|
||||
|
||||
// fmt.Println(len(ids), " = ", len(chunks), "chunks")
|
||||
|
||||
// return nil
|
||||
|
||||
raw, err := GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
searchText := createSearchText(env)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
// return nil
|
||||
|
||||
// ctx := context.Background()
|
||||
// tx, err := db.BeginTx(ctx, nil)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// // roll back if it fails
|
||||
// defer tx.Rollback()
|
||||
|
||||
// // insert mail summary data
|
||||
// _, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ? WHERE ID = ?", searchText, snippet, id)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// return tx.Commit()
|
||||
|
||||
_, err = sqlf.Update("mailbox").
|
||||
Set("SearchText", searchText).
|
||||
Set("Snippet", snippet).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ctx := context.Background()
|
||||
// tx, err := db.BeginTx(ctx, nil)
|
||||
// if err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
|
||||
func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
|
||||
for chunkSize < len(items) {
|
||||
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
|
||||
|
||||
@@ -29,7 +29,7 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
q := searchQueryBuilder(search)
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
@@ -37,24 +37,18 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
var snippet string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &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().Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,6 +79,11 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
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)
|
||||
@@ -100,8 +99,9 @@ func DeleteSearch(search string) error {
|
||||
q := searchQueryBuilder(search)
|
||||
|
||||
ids := []string{}
|
||||
deleteSize := 0
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
var id string
|
||||
var messageID string
|
||||
@@ -109,17 +109,18 @@ func DeleteSearch(search string) error {
|
||||
var metadata string
|
||||
var size int
|
||||
var attachments int
|
||||
var tags string
|
||||
// var tags string
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Error(err)
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
deleteSize = deleteSize + size
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,29 +160,42 @@ func DeleteSearch(search string) error {
|
||||
delIDs[i] = id
|
||||
}
|
||||
|
||||
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
|
||||
|
||||
_, err = tx.Exec(sqlDelete1, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
|
||||
|
||||
_, err = tx.Exec(sqlDelete2, delIDs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDelete3 := `DELETE FROM message_tags WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
|
||||
|
||||
_, 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
|
||||
addDeletedSize(int64(deleteSize))
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
}
|
||||
@@ -191,28 +205,34 @@ func DeleteSearch(search string) error {
|
||||
|
||||
// SearchParser returns the SQL syntax for the database search based on the search arguments
|
||||
func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
searchString = strings.ToLower(searchString)
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
args := tools.ArgsParser(searchString)
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags, Snippet,
|
||||
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("Created DESC")
|
||||
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,
|
||||
IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON
|
||||
`).
|
||||
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]+`)
|
||||
@@ -220,7 +240,7 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(w, "to:") {
|
||||
if strings.HasPrefix(lw, "to:") {
|
||||
w = cleanString(w[3:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -229,7 +249,7 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "from:") {
|
||||
} else if strings.HasPrefix(lw, "from:") {
|
||||
w = cleanString(w[5:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -238,7 +258,7 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "cc:") {
|
||||
} else if strings.HasPrefix(lw, "cc:") {
|
||||
w = cleanString(w[3:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -247,7 +267,7 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "bcc:") {
|
||||
} else if strings.HasPrefix(lw, "bcc:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -256,7 +276,16 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "subject:") {
|
||||
} else if strings.HasPrefix(lw, "reply-to:") {
|
||||
w = cleanString(w[9:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("ReplyToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
} else {
|
||||
q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "subject:") {
|
||||
w = w[8:]
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -265,7 +294,7 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "message-id:") {
|
||||
} else if strings.HasPrefix(lw, "message-id:") {
|
||||
w = cleanString(w[11:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
@@ -274,34 +303,34 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(w, "tag:") {
|
||||
} else if strings.HasPrefix(lw, "tag:") {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%")
|
||||
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("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%")
|
||||
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 w == "is:read" {
|
||||
} else if lw == "is:read" {
|
||||
if exclude {
|
||||
q.Where("Read = 0")
|
||||
} else {
|
||||
q.Where("Read = 1")
|
||||
}
|
||||
} else if w == "is:unread" {
|
||||
} else if lw == "is:unread" {
|
||||
if exclude {
|
||||
q.Where("Read = 1")
|
||||
} else {
|
||||
q.Where("Read = 0")
|
||||
}
|
||||
} else if w == "is:tagged" {
|
||||
} else if lw == "is:tagged" {
|
||||
if exclude {
|
||||
q.Where("Tags = ?", "[]")
|
||||
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("Tags != ?", "[]")
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
}
|
||||
} else if w == "has:attachment" || w == "has:attachments" {
|
||||
} else if lw == "has:attachment" || lw == "has:attachments" {
|
||||
if exclude {
|
||||
q.Where("Attachments = 0")
|
||||
} else {
|
||||
@@ -310,9 +339,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
|
||||
} else {
|
||||
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
|
||||
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@ func TestSearch(t *testing.T) {
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
|
||||
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%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))
|
||||
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)).
|
||||
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
|
||||
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
|
||||
|
||||
env, err := msg.Build()
|
||||
if err != nil {
|
||||
@@ -34,7 +38,9 @@ func TestSearch(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if _, err := Store(buf.Bytes()); err != nil {
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -42,18 +48,26 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
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)
|
||||
uniqueSearches := []string{
|
||||
fmt.Sprintf("from-%d@example.com", i),
|
||||
fmt.Sprintf("from:from-%d@example.com", i),
|
||||
fmt.Sprintf("to-%d@example.com", i),
|
||||
fmt.Sprintf("to:to-%d@example.com", i),
|
||||
fmt.Sprintf("to2-%d@example.com", i),
|
||||
fmt.Sprintf("to:to2-%d@example.com", i),
|
||||
fmt.Sprintf("cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc2-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc2-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
|
||||
fmt.Sprintf("\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, 0, 100)
|
||||
if err != nil {
|
||||
@@ -61,7 +75,7 @@ func TestSearch(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "1 search result expected")
|
||||
assertEqual(t, len(summaries), 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")
|
||||
@@ -85,11 +99,11 @@ func TestSearchDelete100(t *testing.T) {
|
||||
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -123,7 +137,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -150,3 +164,19 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
76
internal/storage/settings.go
Normal file
76
internal/storage/settings.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SettingGet returns a setting string value, blank is it does not exist
|
||||
func SettingGet(k string) string {
|
||||
var result sql.NullString
|
||||
err := sqlf.From("settings").
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", k).
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.String
|
||||
}
|
||||
|
||||
// SettingPut sets a setting string value, inserting if new
|
||||
func SettingPut(k, v string) error {
|
||||
_, err := db.Exec("INSERT INTO settings (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?", k, v, v)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// The total deleted message size as an int64 value
|
||||
func getDeletedSize() int64 {
|
||||
var result sql.NullInt64
|
||||
err := sqlf.From("settings").
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", "DeletedSize").
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Int64
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() int64 {
|
||||
var result sql.NullInt64
|
||||
err := sqlf.From("mailbox").
|
||||
Select("SUM(Size)").To(&result).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Int64
|
||||
}
|
||||
|
||||
// AddDeletedSize will add the value to the DeletedSize setting
|
||||
func addDeletedSize(v int64) {
|
||||
if _, err := db.Exec("INSERT OR IGNORE INTO settings (Key, Value) VALUES(?, ?)", "DeletedSize", 0); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := db.Exec("UPDATE settings SET Value = Value + ? WHERE Key = ?", v, "DeletedSize"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -29,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
|
||||
@@ -79,6 +82,8 @@ type MessageSummary struct {
|
||||
Cc []*mail.Address
|
||||
// Bcc addresses
|
||||
Bcc []*mail.Address
|
||||
// Reply-To address
|
||||
ReplyTo []*mail.Address
|
||||
// Email subject
|
||||
Subject string
|
||||
// Created time
|
||||
@@ -102,10 +107,11 @@ type MailboxStats struct {
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
ReplyTo []*mail.Address
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
@@ -122,3 +128,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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -12,8 +13,12 @@ import (
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetTags will set the tags for a given database ID, used via API
|
||||
func SetTags(id string, tags []string) error {
|
||||
var (
|
||||
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
|
||||
)
|
||||
|
||||
// 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)
|
||||
@@ -22,26 +27,203 @@ func SetTags(id string, tags []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(applyTags)
|
||||
currentTags := getMessageTags(id)
|
||||
origTagCount := len(currentTags)
|
||||
|
||||
tagJSON, err := json.Marshal(applyTags)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] setting tags for message %s", id)
|
||||
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(context.TODO(), 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(context.TODO(), 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(context.TODO(), db)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = sqlf.Update("mailbox").
|
||||
Set("Tags", string(tagJSON)).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(context.Background(), db)
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id)
|
||||
// tag dos not exist, add new one
|
||||
if err := sqlf.InsertInto("tags").
|
||||
Set("Name", name).
|
||||
Returning("ID").To(&tagID).
|
||||
QueryRowAndClose(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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 {
|
||||
@@ -60,24 +242,51 @@ func findTagsInRawMessage(message *[]byte) string {
|
||||
return tagStr
|
||||
}
|
||||
|
||||
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
|
||||
func (d DBMailSummary) tagsFromPlusAddresses() string {
|
||||
tags := []string{}
|
||||
for _, c := range d.To {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
for _, c := range d.Cc {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
for _, c := range d.Bcc {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
matches := addressPlusRe.FindAllStringSubmatch(d.From.String(), 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
|
||||
return strings.Join(tags, ",")
|
||||
}
|
||||
|
||||
// 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 data string
|
||||
var name string
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
Select(`Tags`).To(&data).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
err := q.QueryRowAndClose(context.Background(), db)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
return tags
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &tags); err != nil {
|
||||
logger.Log().Error(err)
|
||||
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(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
return tags
|
||||
}
|
||||
|
||||
@@ -103,7 +312,7 @@ func uniqueTagsFromString(s string) []string {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
|
||||
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,7 @@ func TestTags(t *testing.T) {
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(testMimeEmail)
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -23,7 +24,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := SetTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
if err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -40,4 +41,90 @@ func TestTags(t *testing.T) {
|
||||
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, "|"), "Tags did not delete as expected")
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
49
internal/storage/testdata/tags.eml
vendored
Normal file
49
internal/storage/testdata/tags.eml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
Date: Wed, 27 Jul 2022 15:44:41 +1200
|
||||
From: Sender Smith <sender+FromFag@example.com>
|
||||
To: Recipient Ross <recipient+ToTag@example.com>
|
||||
Cc: Recipient Ross <cc+CcTag@example.com>
|
||||
Bcc: <bcc+BccTag@example.com>
|
||||
Subject: Plain text message
|
||||
X-Tags: X-tag1, X-tag2
|
||||
Message-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
Content-Disposition: inline
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia,
|
||||
fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non
|
||||
hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat,
|
||||
mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at
|
||||
posuere libero. Fusce a gravida nibh. Nulla ac odio ex.
|
||||
|
||||
Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus.
|
||||
Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis
|
||||
sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue
|
||||
ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis
|
||||
eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet
|
||||
orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet.
|
||||
Pellentesque enim nibh, varius at ante id, consequat posuere ante.
|
||||
|
||||
Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra
|
||||
vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec
|
||||
et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque
|
||||
condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet
|
||||
tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus
|
||||
massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor
|
||||
et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur
|
||||
nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum
|
||||
sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel
|
||||
ipsum. Cras condimentum posuere vulputate.
|
||||
|
||||
Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est
|
||||
augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget
|
||||
justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales.
|
||||
Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero
|
||||
venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in
|
||||
nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt.
|
||||
Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum
|
||||
vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse
|
||||
mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu
|
||||
arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis
|
||||
lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget
|
||||
lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
var (
|
||||
testTextEmail []byte
|
||||
testTagEmail []byte
|
||||
testMimeEmail []byte
|
||||
testRuns = 100
|
||||
)
|
||||
@@ -31,6 +32,11 @@ func setup() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testTagEmail, err = os.ReadFile("testdata/tags.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"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/jhillyerd/enmime"
|
||||
"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
|
||||
@@ -69,93 +70,11 @@ func cleanString(str string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
|
||||
}
|
||||
|
||||
// Auto-prune runs every minute to automatically delete oldest messages
|
||||
// if total is greater than the threshold
|
||||
func dbCron() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
start := time.Now()
|
||||
|
||||
// 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)
|
||||
if dbDataDeleted && diff.Minutes() > 5 {
|
||||
dbDataDeleted = false
|
||||
_, err := db.Exec("VACUUM")
|
||||
if err == nil {
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] compressed idle database in %s", elapsed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if config.MaxMessages > 0 {
|
||||
q := sqlf.Select("ID").
|
||||
From("mailbox").
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
if err := q.Query(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
if err := tx.Rollback(); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
dbDataDeleted = true
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -168,7 +87,7 @@ func isFile(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
// Tests if a string is within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
@@ -180,46 +99,7 @@ func inArray(k string, arr []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// escPercentChar replaces `%` with `%%` for SQL searches
|
||||
// Convert `%` to `%%` for SQL searches
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,14 +3,20 @@ package tools
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
|
||||
// TagsTitleCase enforces TitleCase on all tags
|
||||
TagsTitleCase bool
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, removing whitespace and invalid characters
|
||||
@@ -21,5 +27,10 @@ func CleanTag(s string) string {
|
||||
" ",
|
||||
),
|
||||
)
|
||||
|
||||
if TagsTitleCase {
|
||||
return cases.Title(language.Und, cases.NoLower).String(s)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -69,3 +69,51 @@ func TestSnippets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,53 +98,6 @@ func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error)
|
||||
return inputFilePath, outputFilePath, err
|
||||
}
|
||||
|
||||
// Write path without the prefix in subPath to tar writer.
|
||||
func writeTarGz(path string, tarWriter *tar.Writer, fileInfo os.FileInfo, subPath string) error {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
fmt.Printf("Error closing file: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
evaledPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subPath, err = filepath.EvalSymlinks(subPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link := ""
|
||||
if evaledPath != path {
|
||||
link = evaledPath
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(fileInfo, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = evaledPath[len(subPath):]
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract the file in filePath to directory.
|
||||
func extract(filePath string, directory string) error {
|
||||
file, err := os.Open(filepath.Clean(filePath))
|
||||
@@ -200,7 +153,7 @@ func extract(filePath string, directory string) error {
|
||||
|
||||
// set file ownership (if allowed)
|
||||
// Chtimes() && Chmod() only set after once extraction is complete
|
||||
os.Chown(filename, header.Uid, header.Gid) // #nosec
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
|
||||
// add directory info to slice to process afterwards
|
||||
postExtraction = append(postExtraction, DirInfo{filename, header})
|
||||
@@ -249,15 +202,15 @@ func extract(filePath string, directory string) error {
|
||||
}
|
||||
|
||||
// set file permissions, timestamps & uid/gid
|
||||
os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
|
||||
os.Chtimes(filename, header.AccessTime, header.ModTime) // #nosec
|
||||
os.Chown(filename, header.Uid, header.Gid) // #nosec
|
||||
_ = os.Chmod(filename, os.FileMode(header.Mode))
|
||||
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
}
|
||||
|
||||
if len(postExtraction) > 0 {
|
||||
for _, dir := range postExtraction {
|
||||
os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime) // #nosec
|
||||
os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm()) // #nosec
|
||||
_ = os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime)
|
||||
_ = os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,8 +178,8 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
/* #nosec G302 */
|
||||
if err := os.Chmod(newExec, 0755); err != nil {
|
||||
err := os.Chmod(newExec, 0755) // #nosec
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -221,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())
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -305,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
|
||||
@@ -323,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)
|
||||
}
|
||||
|
||||
@@ -339,16 +335,6 @@ func mkDirIfNotExists(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns if a path is a file
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsDir returns if a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
1582
package-lock.json
generated
1582
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
"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",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
@@ -18,7 +18,6 @@
|
||||
"moment": "^2.29.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0",
|
||||
"vue-router": "^4.2.4"
|
||||
@@ -28,8 +27,8 @@
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.19.1",
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^2.3.2"
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,13 @@ 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)
|
||||
|
||||
@@ -15,11 +15,12 @@ import (
|
||||
"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/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
@@ -189,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
|
||||
//
|
||||
@@ -197,7 +200,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -209,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)
|
||||
@@ -279,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
|
||||
//
|
||||
@@ -287,7 +302,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -299,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)
|
||||
@@ -326,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
|
||||
//
|
||||
@@ -334,7 +361,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
@@ -345,9 +372,18 @@ 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)
|
||||
@@ -476,9 +512,9 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// GetTags (method: GET) will get all tags currently in use
|
||||
func GetTags(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/tags tags GetTags
|
||||
// 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
|
||||
//
|
||||
@@ -505,8 +541,8 @@ func GetTags(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// SetTags (method: PUT) will set the tags for all provided IDs
|
||||
func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
@@ -542,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
|
||||
}
|
||||
@@ -554,7 +590,6 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
// If no IDs are provided then all messages are updated.
|
||||
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
|
||||
//
|
||||
@@ -607,7 +642,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
|
||||
httpError(w, "Mail address does not match allowlist: "+to)
|
||||
return
|
||||
}
|
||||
@@ -661,7 +696,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := uuid.New().String() + "@mailpit"
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
// update Message-Id with unique ID
|
||||
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
|
||||
if err != nil {
|
||||
@@ -702,6 +737,16 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -747,6 +792,16 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -767,6 +822,56 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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")
|
||||
|
||||
@@ -3,32 +3,10 @@ package apiv1
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/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, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
@@ -45,27 +23,8 @@ func AppInfo(w http.ResponseWriter, _ *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)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package apiv1
|
||||
import (
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
@@ -50,3 +51,6 @@ type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// LinkCheckResponse summary
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
// SpamAssassinResponse summary
|
||||
type SpamAssassinResponse = spamassassin.Result
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package apiv1
|
||||
|
||||
import "os"
|
||||
import "github.com/axllent/mailpit/internal/stats"
|
||||
|
||||
// These structs are for the purpose of defining swagger HTTP responses
|
||||
// These structs are for the purpose of defining swagger HTTP parameters & responses
|
||||
|
||||
// Application information
|
||||
// swagger:response InfoResponse
|
||||
@@ -10,7 +10,7 @@ type infoResponse struct {
|
||||
// Application information
|
||||
//
|
||||
// in: body
|
||||
Body appInformation
|
||||
Body stats.AppInformation
|
||||
}
|
||||
|
||||
// Web UI configuration
|
||||
@@ -120,20 +120,20 @@ type releaseMessageRequestBody struct {
|
||||
|
||||
// swagger:parameters HTMLCheck
|
||||
type htmlCheckParams struct {
|
||||
// Message database ID
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// swagger:parameters LinkCheck
|
||||
type linkCheckParams struct {
|
||||
// Message database ID
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
@@ -146,12 +146,19 @@ type linkCheckParams struct {
|
||||
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 struct {
|
||||
// in: body
|
||||
File os.File
|
||||
}
|
||||
type binaryResponse string
|
||||
|
||||
// Plain text response
|
||||
// swagger:response TextResponse
|
||||
@@ -161,7 +168,7 @@ type textResponse string
|
||||
// swagger:response HTMLResponse
|
||||
type htmlResponse string
|
||||
|
||||
// Error response
|
||||
// HTTP error response will return with a >= 400 response code
|
||||
// swagger:response ErrorResponse
|
||||
type errorResponse 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
|
||||
}
|
||||
@@ -114,13 +114,13 @@ func blankImage(a *enmime.Part, w http.ResponseWriter) {
|
||||
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
|
||||
img := image.NewRGBA(rect)
|
||||
background := color.RGBA{255, 255, 255, 255}
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src)
|
||||
var b bytes.Buffer
|
||||
foo := bufio.NewWriter(&b)
|
||||
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
|
||||
|
||||
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
|
||||
logger.Log().Warning(err)
|
||||
logger.Log().Warnf("[image] %s", err.Error())
|
||||
}
|
||||
|
||||
fileName := a.FileName
|
||||
|
||||
@@ -20,12 +20,21 @@ type webUIConfiguration struct {
|
||||
SMTPServer string
|
||||
// Enforced Return-Path (if set) for relay bounces
|
||||
ReturnPath string
|
||||
// Allowlist of accepted recipients
|
||||
// Only allow relaying to these recipients (regex)
|
||||
AllowedRecipients string
|
||||
// DEPRECATED 2024/03/12
|
||||
// swagger:ignore
|
||||
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.
|
||||
@@ -51,10 +60,14 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
if config.ReleaseEnabled {
|
||||
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
|
||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic
|
||||
func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
if isReady == nil || !isReady.Load().(bool) {
|
||||
if isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// RedirectToLatestMessage (method: GET) redirects the web UI to the latest message
|
||||
func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
messages := []storage.MessageSummary{}
|
||||
var messages []storage.MessageSummary
|
||||
var err error
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
@@ -78,31 +78,13 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id = messages[0].ID
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
@@ -153,31 +135,13 @@ func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id = messages[0].ID
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
|
||||
@@ -35,7 +35,7 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
@@ -95,7 +95,7 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
address, err := absoluteURL(parts[3], uri)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[proxy] %s", err.Error())
|
||||
return []byte(parts[3])
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// relay status code - WriteHeader must come after Header.Set()
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
w.Write(body)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.Log().Warnf("[proxy] %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// AbsoluteURL will return a full URL regardless whether it is relative or absolute
|
||||
|
||||
76
server/pop3/functions.go
Normal file
76
server/pop3/functions.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
func authUser(username, password string) bool {
|
||||
return auth.POP3Credentials.Match(username, password)
|
||||
}
|
||||
|
||||
// Send a response with debug logging
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
}
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
func getMessages() ([]message, error) {
|
||||
messages := []message{}
|
||||
list, err := storage.List(0, 100)
|
||||
if err != nil {
|
||||
return messages, err
|
||||
}
|
||||
|
||||
for _, m := range list {
|
||||
msg := message{}
|
||||
msg.ID = m.ID
|
||||
msg.Size = m.Size
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// POP3 TOP command returns the headers, followed by the next x lines
|
||||
func getTop(id string, nr int) (string, string, error) {
|
||||
var header, body string
|
||||
raw, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
return header, body, errors.New("-ERR no such message")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(raw), "\r\n\r\n", 2)
|
||||
header = parts[0]
|
||||
lines := []string{}
|
||||
if nr > 0 && len(parts) == 2 {
|
||||
lines = strings.SplitN(parts[1], "\r\n", nr)
|
||||
}
|
||||
|
||||
return header, strings.Join(lines, "\r\n"), nil
|
||||
}
|
||||
|
||||
// cuts the line into command and arguments
|
||||
func getCommand(line string) (string, []string) {
|
||||
line = strings.Trim(line, "\r \n")
|
||||
cmd := strings.Split(line, " ")
|
||||
return cmd[0], cmd[1:]
|
||||
}
|
||||
|
||||
func getSafeArg(args []string, nr int) (string, error) {
|
||||
if nr < len(args) {
|
||||
return args[nr], nil
|
||||
}
|
||||
return "", errors.New("Out of range")
|
||||
}
|
||||
318
server/pop3/pop3.go
Normal file
318
server/pop3/pop3.go
Normal file
@@ -0,0 +1,318 @@
|
||||
// Package pop3 is a simple POP3 server for Mailpit.
|
||||
// By default it is disabled unless password credentials have been loaded.
|
||||
//
|
||||
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
|
||||
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/websockets"
|
||||
)
|
||||
|
||||
const (
|
||||
// UNAUTHORIZED state
|
||||
UNAUTHORIZED = 1
|
||||
// TRANSACTION state
|
||||
TRANSACTION = 2
|
||||
// UPDATE state
|
||||
UPDATE = 3
|
||||
)
|
||||
|
||||
// Run will start the pop3 server if enabled
|
||||
func Run() {
|
||||
if auth.POP3Credentials == nil || config.POP3Listen == "" {
|
||||
// POP3 server is disabled without authentication
|
||||
return
|
||||
}
|
||||
|
||||
var listener net.Listener
|
||||
var err error
|
||||
|
||||
if config.POP3TLSCert != "" {
|
||||
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
|
||||
if err2 != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err2.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cer},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
|
||||
} else {
|
||||
// unencrypted
|
||||
listener, err = net.Listen("tcp", config.POP3Listen)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// run as goroutine
|
||||
go handleClient(conn)
|
||||
}
|
||||
}
|
||||
|
||||
type message struct {
|
||||
ID string
|
||||
Size int
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
|
||||
var (
|
||||
user = ""
|
||||
state = 1
|
||||
toDelete = []string{}
|
||||
)
|
||||
|
||||
defer func() {
|
||||
if state == UPDATE {
|
||||
for _, id := range toDelete {
|
||||
_ = storage.DeleteOneMessage(id)
|
||||
}
|
||||
if len(toDelete) > 0 {
|
||||
// update web UI to remove deleted messages
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.Close(); err != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
messages := []message{}
|
||||
|
||||
// State
|
||||
// 1 = Unauthorized
|
||||
// 2 = Transaction mode
|
||||
// 3 = update mode
|
||||
|
||||
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
|
||||
|
||||
// First welcome the new connection
|
||||
sendResponse(conn, "+OK Mailpit POP3 server")
|
||||
|
||||
timeoutDuration := 30 * time.Second
|
||||
|
||||
for {
|
||||
// POP3 server enforced a timeout of 30 seconds
|
||||
if err := conn.SetDeadline(time.Now().Add(timeoutDuration)); err != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reads a line from the client
|
||||
rawLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parses the command
|
||||
cmd, args := getCommand(rawLine)
|
||||
|
||||
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
|
||||
|
||||
if cmd == "CAPA" {
|
||||
// List our capabilities per RFC2449
|
||||
sendResponse(conn, "+OK Capability list follows")
|
||||
sendResponse(conn, "TOP")
|
||||
sendResponse(conn, "USER")
|
||||
sendResponse(conn, "UIDL")
|
||||
sendResponse(conn, "IMPLEMENTATION Mailpit")
|
||||
sendResponse(conn, ".")
|
||||
continue
|
||||
} else if cmd == "USER" && state == UNAUTHORIZED {
|
||||
if len(args) != 1 {
|
||||
sendResponse(conn, "-ERR must supply a user")
|
||||
return
|
||||
}
|
||||
// always true - stash for PASS
|
||||
sendResponse(conn, "+OK")
|
||||
user = args[0]
|
||||
|
||||
} else if cmd == "PASS" && state == UNAUTHORIZED {
|
||||
if len(args) != 1 {
|
||||
sendResponse(conn, "-ERR must supply a password")
|
||||
return
|
||||
}
|
||||
|
||||
pass := args[0]
|
||||
if authUser(user, pass) {
|
||||
sendResponse(conn, "+OK signed in")
|
||||
messages, err = getMessages()
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[pop3] %s", err.Error())
|
||||
}
|
||||
state = 2
|
||||
} else {
|
||||
sendResponse(conn, "-ERR invalid password")
|
||||
logger.Log().Warnf("[pop3] failed login: %s", user)
|
||||
}
|
||||
|
||||
} else if cmd == "STAT" && state == TRANSACTION {
|
||||
totalSize := 0
|
||||
for _, m := range messages {
|
||||
totalSize = totalSize + m.Size
|
||||
}
|
||||
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize))
|
||||
|
||||
} else if cmd == "LIST" && state == TRANSACTION {
|
||||
totalSize := 0
|
||||
for _, m := range messages {
|
||||
totalSize = totalSize + m.Size
|
||||
}
|
||||
sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize))
|
||||
|
||||
// print all sizes
|
||||
for row, m := range messages {
|
||||
sendData(conn, fmt.Sprintf("%d %d", row+1, m.Size))
|
||||
}
|
||||
// end
|
||||
sendData(conn, ".")
|
||||
|
||||
} else if cmd == "UIDL" && state == TRANSACTION {
|
||||
totalSize := 0
|
||||
for _, m := range messages {
|
||||
totalSize = totalSize + m.Size
|
||||
}
|
||||
|
||||
sendData(conn, "+OK unique-id listing follows")
|
||||
|
||||
// print all message IDS
|
||||
for row, m := range messages {
|
||||
sendData(conn, fmt.Sprintf("%d %s", row+1, m.ID))
|
||||
}
|
||||
// end
|
||||
sendData(conn, ".")
|
||||
|
||||
} else if cmd == "RETR" && state == TRANSACTION {
|
||||
if len(args) != 1 {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
nr, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
if nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
m := messages[nr-1]
|
||||
raw, err := storage.GetMessageRaw(m.ID)
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
size := len(raw)
|
||||
sendData(conn, fmt.Sprintf("+OK %d octets", size))
|
||||
sendData(conn, string(raw))
|
||||
sendData(conn, ".")
|
||||
|
||||
} else if cmd == "TOP" && state == TRANSACTION {
|
||||
arg, err := getSafeArg(args, 0)
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR TOP requires two arguments")
|
||||
return
|
||||
}
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR TOP requires two arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
arg2, err := getSafeArg(args, 1)
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR TOP requires two arguments")
|
||||
return
|
||||
}
|
||||
|
||||
lines, err := strconv.Atoi(arg2)
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR TOP requires two arguments")
|
||||
return
|
||||
}
|
||||
|
||||
m := messages[nr-1]
|
||||
headers, body, err := getTop(m.ID, lines)
|
||||
if err != nil {
|
||||
sendResponse(conn, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sendData(conn, "+OK Top of message follows")
|
||||
sendData(conn, headers+"\r\n")
|
||||
sendData(conn, body)
|
||||
sendData(conn, ".")
|
||||
|
||||
} else if cmd == "NOOP" && state == TRANSACTION {
|
||||
sendData(conn, "+OK")
|
||||
} else if cmd == "DELE" && state == TRANSACTION {
|
||||
arg, _ := getSafeArg(args, 0)
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[pop3] -ERR invalid DELETE integer: %s", arg)
|
||||
sendResponse(conn, "-ERR invalid integer")
|
||||
return
|
||||
}
|
||||
|
||||
if nr < 1 || nr > len(messages) {
|
||||
logger.Log().Warnf("[pop3] -ERR no such message")
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
toDelete = append(toDelete, messages[nr-1].ID)
|
||||
|
||||
sendResponse(conn, "+OK")
|
||||
|
||||
} else if cmd == "RSET" && state == TRANSACTION {
|
||||
toDelete = []string{}
|
||||
sendData(conn, "+OK")
|
||||
|
||||
} else if cmd == "QUIT" {
|
||||
state = UPDATE
|
||||
return
|
||||
} else {
|
||||
logger.Log().Warnf("[pop3] -ERR %s not implemented", cmd)
|
||||
sendResponse(conn, fmt.Sprintf("-ERR %s not implemented", cmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"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/pop3"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -34,10 +37,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)
|
||||
}
|
||||
|
||||
@@ -45,6 +49,8 @@ func Listen() {
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
|
||||
go pop3.Run()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
// kubernetes probes
|
||||
@@ -90,12 +96,20 @@ func Listen() {
|
||||
// Mark the application here as ready
|
||||
isReady.Store(true)
|
||||
|
||||
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: config.HTTPListen,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
if config.UITLSCert != "" && config.UITLSKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
|
||||
logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(server.ListenAndServeTLS(config.UITLSCert, config.UITLSKey))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(server.ListenAndServe())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +120,8 @@ func apiRoutes() *mux.Router {
|
||||
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.GetTags)).Methods("GET")
|
||||
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")
|
||||
@@ -119,6 +133,9 @@ func apiRoutes() *mux.Router {
|
||||
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")
|
||||
@@ -286,7 +303,7 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
</head>
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
// store this for later tests
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
@@ -56,7 +54,6 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
|
||||
// read first 10 messages
|
||||
t.Log("Read first 10 messages including raw & headers")
|
||||
putIDS := []string{}
|
||||
for idx, msg := range m.Messages {
|
||||
if idx == 10 {
|
||||
break
|
||||
@@ -71,13 +68,10 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// het headers
|
||||
// get headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
// store for later
|
||||
putIDS = append(putIDS, msg.ID)
|
||||
}
|
||||
|
||||
// 10 should be marked as read
|
||||
@@ -186,7 +180,7 @@ func TestAPIv1Search(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// insert 100
|
||||
t.Log("Insert 100 messages")
|
||||
t.Log("Insert 100 messages & tag")
|
||||
insertEmailData(t)
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
@@ -194,13 +188,17 @@ func TestAPIv1Search(t *testing.T) {
|
||||
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", "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", 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() {
|
||||
@@ -272,7 +270,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()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func allowedRecipients(to []string) []string {
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
|
||||
if config.SMTPRelayConfig.AllowedRecipientsRegexp == nil {
|
||||
return to
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ func allowedRecipients(to []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
|
||||
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.RecipientAllowlist)
|
||||
if !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
|
||||
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.AllowedRecipients)
|
||||
} else {
|
||||
ar = append(ar, recipient)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func Send(from string, to []string, msg []byte) error {
|
||||
defer c.Close()
|
||||
|
||||
if config.SMTPRelayConfig.STARTTLS {
|
||||
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host}
|
||||
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host} // #nosec
|
||||
|
||||
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
|
||||
|
||||
@@ -127,7 +127,7 @@ func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,17 @@ import (
|
||||
"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/google/uuid"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/mhale/smtpd"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -27,7 +33,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
|
||||
stats.LogSMTPRejected()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,12 +63,13 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
// add a message ID if not set
|
||||
if messageID == "" {
|
||||
// generate unique ID
|
||||
messageID = uuid.New().String() + "@mailpit"
|
||||
messageID = shortuuid.New() + "@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
|
||||
}
|
||||
}
|
||||
@@ -116,13 +123,16 @@ 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: %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)
|
||||
|
||||
@@ -147,35 +157,70 @@ func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []
|
||||
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 auth.SMTPCredentials != nil {
|
||||
logger.Log().Info("[smtpd] enabling login auth (insecure)")
|
||||
logger.Log().Info("[smtpd] enabling login authentication (insecure)")
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling all auth (insecure)")
|
||||
logger.Log().Info("[smtpd] enabling any authentication (insecure)")
|
||||
}
|
||||
} else {
|
||||
if auth.SMTPCredentials != nil {
|
||||
logger.Log().Info("[smtpd] enabling login auth (TLS)")
|
||||
logger.Log().Info("[smtpd] enabling login authentication")
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
logger.Log().Info("[smtpd] enabling any auth (TLS)")
|
||||
logger.Log().Info("[smtpd] enabling any authentication")
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
|
||||
smtpType := "no encryption"
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
if config.SMTPRequireSTARTTLS {
|
||||
smtpType = "STARTTLS required"
|
||||
} else if config.SMTPRequireTLS {
|
||||
smtpType = "SSL/TLS required"
|
||||
} else {
|
||||
smtpType = "STARTTLS optional"
|
||||
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {
|
||||
smtpType = "STARTTLS required"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
logger.Log().Infof("[smtpd] starting on %s (%s)", config.SMTPListen, smtpType)
|
||||
|
||||
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 {
|
||||
@@ -192,6 +237,8 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
|
||||
}
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
srv.TLSRequired = config.SMTPRequireSTARTTLS
|
||||
srv.TLSListener = config.SMTPRequireTLS // if true overrules srv.TLSRequired
|
||||
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import CommonMixins from './mixins/CommonMixins'
|
||||
import Favicon from './components/Favicon.vue'
|
||||
import Notifications from './components/Notifications.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
@@ -8,12 +9,13 @@ export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
Favicon,
|
||||
Notifications,
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
document.title = document.title + ' - ' + location.hostname
|
||||
mailbox.showTagColors = localStorage.getItem('showTagsColors') == '1'
|
||||
mailbox.showTagColors = !localStorage.getItem('hideTagColors') == '1'
|
||||
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
@@ -33,5 +35,6 @@ export default {
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Favicon />
|
||||
<Notifications />
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
// @import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
@extend a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -124,6 +129,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.text-spaces-nowrap {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.text-spaces {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#nav-plain-text .text-view,
|
||||
#nav-source {
|
||||
white-space: pre;
|
||||
@@ -146,6 +159,7 @@
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
td {
|
||||
@@ -319,6 +333,12 @@ body.blur {
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu.checks {
|
||||
.dropdown-item {
|
||||
min-width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrap5-tags
|
||||
.tags-badge {
|
||||
display: flex;
|
||||
|
||||
@@ -82,7 +82,7 @@ export default {
|
||||
requestNotifications: function () {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notification")
|
||||
alert("This browser does not support desktop notifications")
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
@@ -148,10 +148,11 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<div class="modal modal-xl fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" v-if="mailbox.appInfo">
|
||||
<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>
|
||||
@@ -159,51 +160,123 @@ export default {
|
||||
<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-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 class="col-xl-6">
|
||||
<div class="row g-3" v-if="mailbox.appInfo.LatestVersion == ''">
|
||||
<div class="alert alert-warning mb-3">
|
||||
There might be a newer version available. The check failed.
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3" v-else-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion">
|
||||
<a class="btn btn-warning d-block mb-3"
|
||||
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
|
||||
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
|
||||
</a>
|
||||
</div>
|
||||
<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-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.Memory) }} </h5>
|
||||
<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 up since
|
||||
</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>
|
||||
|
||||
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>
|
||||
@@ -122,13 +122,13 @@ export default {
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="message.From.Address">{{
|
||||
<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="message.From.Address">{{
|
||||
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</b>
|
||||
@@ -141,7 +141,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
<div class="subject text-truncate">
|
||||
<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">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<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,
|
||||
@@ -12,6 +15,22 @@ export default {
|
||||
},
|
||||
|
||||
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')
|
||||
@@ -21,6 +40,35 @@ export default {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +92,7 @@ export default {
|
||||
</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>
|
||||
|
||||
@@ -15,10 +15,16 @@ export default {
|
||||
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`)
|
||||
|
||||
@@ -35,10 +41,13 @@ export default {
|
||||
let ws = new WebSocket(this.socketURI)
|
||||
let self = this
|
||||
ws.onmessage = function (e) {
|
||||
let response = JSON.parse(e.data)
|
||||
if (!response) {
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(e.data)
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (!mailbox.searching) {
|
||||
@@ -79,6 +88,11 @@ export default {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
pagination
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,6 +34,12 @@ export default {
|
||||
if (this.search == '') {
|
||||
this.$router.push('/')
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
let curr = urlParams.get('q')
|
||||
if (curr && curr == this.search) {
|
||||
pagination.start = 0
|
||||
this.$emit('loadMessages')
|
||||
}
|
||||
this.$router.push('/search?q=' + encodeURIComponent(this.search))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
import HTMLCheck from './HTMLCheck.vue'
|
||||
import Headers from './Headers.vue'
|
||||
import HTMLCheck from './HTMLCheck.vue'
|
||||
import LinkCheck from './LinkCheck.vue'
|
||||
import SpamAssassin from './SpamAssassin.vue'
|
||||
import Prism from 'prismjs'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
|
||||
@@ -19,6 +21,7 @@ export default {
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
LinkCheck,
|
||||
SpamAssassin,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
@@ -34,7 +37,10 @@ export default {
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
linkCheckErrors: false,
|
||||
spamScore: false,
|
||||
spamScoreColor: false,
|
||||
showMobileButtons: false,
|
||||
showUnsubscribe: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
responsiveSizes: {
|
||||
@@ -117,6 +123,9 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
window.setTimeout(function () {
|
||||
let p = document.getElementById('preview-html')
|
||||
@@ -230,7 +239,7 @@ export default {
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Address" class="small">
|
||||
<<a :href="searchURI(message.From.Address)" class="text-body">
|
||||
{{ message.From.Address }}
|
||||
@@ -240,15 +249,23 @@ export default {
|
||||
<span v-else>
|
||||
[ Unknown ]
|
||||
</span>
|
||||
|
||||
<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link"
|
||||
:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'"
|
||||
@click="showUnsubscribe = !showUnsubscribe">
|
||||
Unsubscribe
|
||||
<i class="bi bi bi-info-circle"
|
||||
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
|
||||
<span v-if="message.To && message.To.length" v-for="( t, i ) in message.To ">
|
||||
<template v-if="i > 0">, </template>
|
||||
<span>
|
||||
{{ t.Name }}
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
@@ -260,9 +277,9 @@ export default {
|
||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||
<th>Cc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<span v-for="( t, i ) in message.Cc ">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
@@ -272,9 +289,9 @@ export default {
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<span v-for="( t, i ) in message.Bcc ">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
@@ -286,14 +303,15 @@ export default {
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name }}
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||
{{ t.Address }}
|
||||
</a>>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="message.ReturnPath && message.ReturnPath != message.From.Address" class="small">
|
||||
<tr v-if="message.ReturnPath && message.From && message.ReturnPath != message.From.Address"
|
||||
class="small">
|
||||
<th class="text-nowrap">Return-Path</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<<a :href="searchURI(message.ReturnPath)" class="text-body-secondary">
|
||||
@@ -304,7 +322,7 @@ export default {
|
||||
<tr>
|
||||
<th class="small">Subject</th>
|
||||
<td>
|
||||
<strong v-if="message.Subject != ''">{{ message.Subject }}</strong>
|
||||
<strong v-if="message.Subject != ''" class="text-spaces">{{ message.Subject }}</strong>
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -319,7 +337,7 @@ export default {
|
||||
<select class="form-select small tag-selector" v-model="messageTags" multiple
|
||||
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
|
||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_]){3,}$"
|
||||
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_\.]){1,}$"
|
||||
data-separator="|,|">
|
||||
<option value="">Type a tag...</option>
|
||||
<!-- you need at least one option with the placeholder -->
|
||||
@@ -328,6 +346,29 @@ export default {
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="message.ListUnsubscribe.Header != ''" class="small"
|
||||
:class="showUnsubscribe ? '' : 'd-none'">
|
||||
<th>Unsubscribe</th>
|
||||
<td>
|
||||
<span v-if="message.ListUnsubscribe.Links.length" class="text-secondary small me-2">
|
||||
<template v-for="(u, i) in message.ListUnsubscribe.Links">
|
||||
<template v-if="i > 0">, </template>
|
||||
<{{ u }}>
|
||||
</template>
|
||||
</span>
|
||||
<i class="bi bi-info-circle text-success me-2 link"
|
||||
v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost">
|
||||
</i>
|
||||
<i class="bi bi-exclamation-circle text-danger link"
|
||||
v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip"
|
||||
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
|
||||
:data-bs-title="message.ListUnsubscribe.Errors">
|
||||
</i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -385,13 +426,14 @@ export default {
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Checks
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<ul class="dropdown-menu checks">
|
||||
<li>
|
||||
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
@@ -401,12 +443,25 @@ export default {
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false">
|
||||
Link Check
|
||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
@@ -426,9 +481,17 @@ export default {
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="vals, key in responsiveSizes">
|
||||
<template v-for="_, key in responsiveSizes">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
<i class="bi" :class="'bi-' + key"></i>
|
||||
@@ -471,6 +534,11 @@ export default {
|
||||
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
|
||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
||||
tabindex="0">
|
||||
<SpamAssassin v-if="mailbox.uiConfig.SpamAssassin" :message="message" @setSpamScore="(n) => spamScore = n"
|
||||
@set-badge-style="(v) => spamScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="message.From.Address">
|
||||
:value="message.From ? message.From.Address : ''">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -126,10 +126,10 @@ export default {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.RecipientAllowlist != ''">
|
||||
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
|
||||
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
|
||||
<br class="d-none d-md-inline">
|
||||
Configured allowlist: <b>{{ mailbox.uiConfig.MessageRelay.RecipientAllowlist }}</b>
|
||||
Allowed recipients: <b>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</b>
|
||||
</div>
|
||||
<div class="form-text text-center">
|
||||
Note: For testing purposes, a unique Message-Id will be generated on send.
|
||||
|
||||
297
server/ui-src/components/message/SpamAssassin.vue
Normal file
297
server/ui-src/components/message/SpamAssassin.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
check: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.doCheck()
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setSpamScore', false)
|
||||
this.doCheck()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck: function () {
|
||||
this.check = false
|
||||
|
||||
let self = this
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/sa-check'), null)
|
||||
.then(function (result) {
|
||||
self.check = result.data
|
||||
self.error = false
|
||||
self.setIcons()
|
||||
})
|
||||
.catch(function (error) {
|
||||
// handle error
|
||||
if (error.response && error.response.data) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
self.error = error.response.data.Error
|
||||
} else {
|
||||
self.error = error.response.data
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
self.error = 'Error sending data to the server. Please try again.'
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
self.error = error.message
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
badgeStyle: function (ignorePadding = false) {
|
||||
let badgeStyle = 'bg-success'
|
||||
if (this.check.Error) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
}
|
||||
else if (this.check.IsSpam) {
|
||||
badgeStyle = 'bg-danger'
|
||||
} else if (this.check.Score >= 4) {
|
||||
badgeStyle = 'bg-warning text-primary'
|
||||
}
|
||||
|
||||
if (!ignorePadding && String(this.check.Score).includes('.')) {
|
||||
badgeStyle += " p-1"
|
||||
}
|
||||
|
||||
return badgeStyle
|
||||
},
|
||||
|
||||
setIcons: function () {
|
||||
let score = this.check.Score
|
||||
if (this.check.Error && this.check.Error != '') {
|
||||
score = '!'
|
||||
}
|
||||
let badgeStyle = this.badgeStyle()
|
||||
this.$emit('setBadgeStyle', badgeStyle)
|
||||
this.$emit('setSpamScore', score)
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
graphSections: function () {
|
||||
let score = this.check.Score
|
||||
let p = Math.round(score / 5 * 100)
|
||||
if (p > 100) {
|
||||
p = 100
|
||||
} else if (p < 0) {
|
||||
p = 0
|
||||
}
|
||||
|
||||
let c = '#ffc107'
|
||||
if (this.check.IsSpam) {
|
||||
c = '#dc3545'
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: score + ' / 5',
|
||||
value: p,
|
||||
color: c
|
||||
},
|
||||
];
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row mb-3 w-100 align-items-center">
|
||||
<div class="col">
|
||||
<h4 class="mb-0">Spam Analysis</h4>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#AboutSpamAnalysis">
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="error || check.Error != ''">
|
||||
<p>Your message could not be checked</p>
|
||||
<div class="alert alert-warning" v-if="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div class="alert alert-warning" v-else>
|
||||
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="check">
|
||||
<div class="row w-100 mt-5">
|
||||
<div class="col-xl-5 mb-2">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ check.Score }} / 5
|
||||
</h2>
|
||||
<div class="text-body mt-2">
|
||||
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||
</div>
|
||||
</Donut>
|
||||
</div>
|
||||
<div class="col-xl-7">
|
||||
<div class="row w-100 py-2 border-bottom">
|
||||
<div class="col-2 col-lg-1">
|
||||
<strong>Score</strong>
|
||||
</div>
|
||||
<div class="col-10 col-lg-5">
|
||||
<strong>Rule <span class="d-none d-lg-inline">name</span></strong>
|
||||
</div>
|
||||
<div class="col-auto d-none d-lg-block">
|
||||
<strong>Description</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules">
|
||||
<div class="col-2 col-lg-1">
|
||||
{{ r.Score }}
|
||||
</div>
|
||||
<div class="col-10 col-lg-5">
|
||||
{{ r.Name }}
|
||||
</div>
|
||||
<div class="col-auto col-lg-6 mt-2 mt-lg-0 offset-2 offset-lg-0">
|
||||
{{ r.Description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Spam Analysis is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
What is Spam Analysis?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||
"spamminess" of your messages. It sends your complete message (including any
|
||||
attachments) to a running SpamAssassin server and then displays the results returned
|
||||
by SpamAssassin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
How does the point system work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||
considered ham (not spam), and any score of 5 or above is spam.
|
||||
</p>
|
||||
<p>
|
||||
SpamAssassin will also return the tests which are triggered by the message. These
|
||||
tests can differ depending on the configuration of your SpamAssassin server. The
|
||||
total of this score makes up the the "spamminess" of the message.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
But I don't agree with the results...
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Mailpit does not manipulate the results nor determine the "spamminess" of
|
||||
your message. The result is what SpamAssassin returns, and it entirely
|
||||
dependent on how SpamAssassin is set up and optionally trained.
|
||||
</p>
|
||||
<p>
|
||||
This tool is simply provided as an aid to assist you. If you are running your own
|
||||
instance of SpamAssassin, then you look into your SpamAssassin configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
Where can I find more information about the triggered rules?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
||||
target="_blank">SpamAssassin website</a> no longer contains any relative
|
||||
documentation
|
||||
about these, most likely because the rules come from different locations and change
|
||||
often. You will need to search the internet for these yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,6 +33,9 @@ export default {
|
||||
},
|
||||
|
||||
getFileSize: function (bytes) {
|
||||
if (bytes == 0) {
|
||||
return '0B'
|
||||
}
|
||||
var i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
|
||||
},
|
||||
@@ -45,6 +48,10 @@ export default {
|
||||
return moment(d).format('ddd, D MMM YYYY, h:mm a')
|
||||
},
|
||||
|
||||
secondsToRelative: function (d) {
|
||||
return moment().subtract(d, 'seconds').fromNow()
|
||||
},
|
||||
|
||||
tagEncodeURI: function (tag) {
|
||||
if (tag.match(/ /)) {
|
||||
tag = `"${tag}"`
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
@@ -1,14 +1,6 @@
|
||||
// State Management
|
||||
|
||||
import { reactive, watch } from 'vue'
|
||||
import Tinycon from 'tinycon'
|
||||
|
||||
Tinycon.setOptions({
|
||||
height: 11,
|
||||
background: '#dd0000',
|
||||
fallback: false,
|
||||
font: '9px arial',
|
||||
})
|
||||
|
||||
// global mailbox info
|
||||
export const mailbox = reactive({
|
||||
@@ -17,7 +9,7 @@ export const mailbox = reactive({
|
||||
count: 0, // total in mailbox or search
|
||||
messages: [], // current messages
|
||||
tags: [], // all tags
|
||||
showTagColors: false, // show tag colors?
|
||||
showTagColors: true, // show/hide tag colors
|
||||
selected: [], // currently selected
|
||||
connected: false, // websocket connection
|
||||
searching: false, // current search, false for none
|
||||
@@ -29,17 +21,6 @@ export const mailbox = reactive({
|
||||
lastMessage: false, // return scrolling
|
||||
})
|
||||
|
||||
watch(
|
||||
() => mailbox.unread,
|
||||
(v) => {
|
||||
if (v == 0) {
|
||||
Tinycon.reset()
|
||||
} else {
|
||||
Tinycon.setBubble(v)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailbox.count,
|
||||
(v) => {
|
||||
@@ -51,9 +32,9 @@ watch(
|
||||
() => mailbox.showTagColors,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.setItem('showTagsColors', '1')
|
||||
localStorage.removeItem('hideTagColors')
|
||||
} else {
|
||||
localStorage.removeItem('showTagsColors')
|
||||
localStorage.setItem('hideTagColors', '1')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -341,6 +341,6 @@ export default {
|
||||
|
||||
<AboutMailpit modals />
|
||||
<AjaxLoader :loading="loading" />
|
||||
<Release v-if="message" ref="ReleaseRef" :message="message" @delete="deleteMessage" />
|
||||
<Release v-if="mailbox.uiConfig.MessageRelay && message" ref="ReleaseRef" :message="message" @delete="deleteMessage" />
|
||||
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
|
||||
</template>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-md-4k col-lg-5 col-xl-6">
|
||||
<SearchForm />
|
||||
<SearchForm @loadMessages="loadMessages" />
|
||||
</div>
|
||||
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
|
||||
<div class="float-start d-md-none">
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@ export default {
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}": {
|
||||
"get": {
|
||||
"description": "Returns the summary of a message, marking the message as read.",
|
||||
"description": "Returns the summary of a message, marking the message as read.\n\nThe ID can be set to `latest` to return the latest message.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -66,7 +66,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Message database ID",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -87,7 +87,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/headers": {
|
||||
"get": {
|
||||
"description": "Returns the message headers as an array.",
|
||||
"description": "Returns the message headers as an array.\n\nThe ID can be set to `latest` to return the latest message headers.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -103,7 +103,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Database ID",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -140,7 +140,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Message database ID",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -177,7 +177,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Message database ID",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/raw": {
|
||||
"get": {
|
||||
"description": "Returns the full email source as plain text.",
|
||||
"description": "Returns the full email source as plain text.\n\nThe ID can be set to `latest` to return the latest message source.",
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
@@ -306,7 +306,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Database ID",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@@ -366,6 +366,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/sa-check": {
|
||||
"get": {
|
||||
"description": "Returns the SpamAssassin (if enabled) summary of the message.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "SpamAssassin check (beta)",
|
||||
"operationId": "SpamAssassinCheck",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Message database ID or \"latest\"",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SpamAssassinResponse",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/SpamAssassinResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/messages": {
|
||||
"get": {
|
||||
"description": "Returns messages from the mailbox ordered from newest to oldest.",
|
||||
@@ -571,7 +608,7 @@
|
||||
"tags"
|
||||
],
|
||||
"summary": "Get all current tags",
|
||||
"operationId": "GetTags",
|
||||
"operationId": "GetAllTags",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ArrayResponse"
|
||||
@@ -727,7 +764,7 @@
|
||||
"x-go-package": "net/mail"
|
||||
},
|
||||
"AppInformation": {
|
||||
"description": "Response includes the current and latest Mailpit version, database info, and memory usage",
|
||||
"description": "AppInformation struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Database": {
|
||||
@@ -743,23 +780,71 @@
|
||||
"description": "Latest Mailpit version",
|
||||
"type": "string"
|
||||
},
|
||||
"Memory": {
|
||||
"description": "Current memory usage in bytes",
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"Messages": {
|
||||
"description": "Total number of messages in the database",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"RuntimeStats": {
|
||||
"description": "Runtime statistics",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Memory": {
|
||||
"description": "Current memory usage in bytes",
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"MessagesDeleted": {
|
||||
"description": "Database runtime messages deleted",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"SMTPAccepted": {
|
||||
"description": "Accepted runtime SMTP messages",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"SMTPAcceptedSize": {
|
||||
"description": "Total runtime accepted messages size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"SMTPIgnored": {
|
||||
"description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"SMTPRejected": {
|
||||
"description": "Rejected runtime SMTP messages",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Uptime": {
|
||||
"description": "Mailpit server uptime in seconds",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tags": {
|
||||
"description": "Tags and message totals per tag",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"Unread": {
|
||||
"description": "Total number of messages in the database",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Version": {
|
||||
"description": "Current Mailpit version",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-name": "appInformation",
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/stats"
|
||||
},
|
||||
"Attachment": {
|
||||
"description": "Attachment struct for inline and attachments",
|
||||
@@ -809,11 +894,6 @@
|
||||
"x-go-name": "deleteMessagesRequestBody",
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"File": {
|
||||
"type": "object",
|
||||
"title": "File represents an open file descriptor.",
|
||||
"x-go-package": "os"
|
||||
},
|
||||
"HTMLCheckResponse": {
|
||||
"description": "Response represents the HTML check response struct",
|
||||
"type": "object",
|
||||
@@ -1179,6 +1259,13 @@
|
||||
"description": "Read status",
|
||||
"type": "boolean"
|
||||
},
|
||||
"ReplyTo": {
|
||||
"description": "Reply-To address",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Address"
|
||||
}
|
||||
},
|
||||
"Size": {
|
||||
"description": "Message size in bytes (total)",
|
||||
"type": "integer",
|
||||
@@ -1256,6 +1343,54 @@
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"Rule": {
|
||||
"description": "Rule struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Description": {
|
||||
"description": "SpamAssassin rule description",
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"description": "SpamAssassin rule name",
|
||||
"type": "string"
|
||||
},
|
||||
"Score": {
|
||||
"description": "Spam rule score",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
|
||||
},
|
||||
"SpamAssassinResponse": {
|
||||
"description": "Result is a SpamAssassin result",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Error": {
|
||||
"description": "If populated will return an error string",
|
||||
"type": "string"
|
||||
},
|
||||
"IsSpam": {
|
||||
"description": "Whether the message is spam or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"Rules": {
|
||||
"description": "Spam rules triggered",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Rule"
|
||||
}
|
||||
},
|
||||
"Score": {
|
||||
"description": "Total spam score based on triggered rules",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Result",
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
|
||||
},
|
||||
"WebUIConfiguration": {
|
||||
"description": "Response includes global web UI settings",
|
||||
"type": "object",
|
||||
@@ -1264,18 +1399,22 @@
|
||||
"description": "Whether the HTML check has been globally disabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"DuplicatesIgnored": {
|
||||
"description": "Whether messages with duplicate IDs are ignored",
|
||||
"type": "boolean"
|
||||
},
|
||||
"MessageRelay": {
|
||||
"description": "Message Relay information",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"AllowedRecipients": {
|
||||
"description": "Only allow relaying to these recipients (regex)",
|
||||
"type": "string"
|
||||
},
|
||||
"Enabled": {
|
||||
"description": "Whether message relaying (release) is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"RecipientAllowlist": {
|
||||
"description": "Allowlist of accepted recipients",
|
||||
"type": "string"
|
||||
},
|
||||
"ReturnPath": {
|
||||
"description": "Enforced Return-Path (if set) for relay bounces",
|
||||
"type": "string"
|
||||
@@ -1285,6 +1424,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SpamAssassin": {
|
||||
"description": "Whether SpamAssassin is enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"x-go-name": "webUIConfiguration",
|
||||
@@ -1387,11 +1530,11 @@
|
||||
"BinaryResponse": {
|
||||
"description": "Binary data response inherits the attachment's content type",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/File"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"description": "Error response",
|
||||
"description": "HTTP error response will return with a \u003e= 400 response code",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ func Send(msg interface{}) {
|
||||
rl.Do(func() {
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[webhook] invalid data: %s", err)
|
||||
logger.Log().Errorf("[webhook] invalid data: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.WebhookURL, bytes.NewBuffer(b))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[webhook] error: %s", err)
|
||||
logger.Log().Errorf("[webhook] error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,12 +57,12 @@ func Send(msg interface{}) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[webhook] error sending data: %s", err)
|
||||
logger.Log().Errorf("[webhook] error sending data: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
logger.Log().Warningf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode)
|
||||
logger.Log().Warnf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -22,14 +22,10 @@ const (
|
||||
|
||||
// Send pings to peer with this period. Must be less than pongWait.
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
|
||||
// Maximum message size allowed from peer.
|
||||
maxMessageSize = 512
|
||||
)
|
||||
|
||||
var (
|
||||
newline = []byte{'\n'}
|
||||
space = []byte{' '}
|
||||
|
||||
// MessageHub global
|
||||
MessageHub *Hub
|
||||
@@ -60,10 +56,10 @@ func (c *Client) readPump() {
|
||||
}()
|
||||
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
_, _, err := c.conn.NextReader()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
logger.Log().Errorf("[websocket] error: %v", err)
|
||||
logger.Log().Errorf("[websocket] error: %v", err.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -132,7 +128,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Log().Error(err)
|
||||
logger.Log().Errorf("[websocket] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func Broadcast(t string, msg interface{}) {
|
||||
b, err := json.Marshal(w)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err)
|
||||
logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user