mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-06 21:47:02 +00:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb869fb5e | ||
|
|
31390e4b82 | ||
|
|
3974fdfbaf | ||
|
|
9909fd969c | ||
|
|
abd1f0b008 | ||
|
|
0dbbb821eb | ||
|
|
262be51c9b | ||
|
|
5dee4cc763 | ||
|
|
f89fa46902 | ||
|
|
c25dee57c3 | ||
|
|
e192d5efd2 | ||
|
|
0de93c7868 | ||
|
|
3e28acde6a | ||
|
|
ae05840571 | ||
|
|
4269192f32 | ||
|
|
35fb3d1790 | ||
|
|
0ec2f8bc61 | ||
|
|
ed4618a1f3 | ||
|
|
09f50f64fd | ||
|
|
f87ec396c9 | ||
|
|
3e37293c99 | ||
|
|
7147032c6b | ||
|
|
3c36951113 | ||
|
|
86e8a126ca | ||
|
|
7f586e15cf | ||
|
|
2a5559f5f0 | ||
|
|
ead3fad1dd | ||
|
|
abd546133e | ||
|
|
fae0384dfe | ||
|
|
aa1a5a0954 | ||
|
|
c81ea54c87 | ||
|
|
ebf7bb6348 | ||
|
|
ba0e40fc7f | ||
|
|
9f0d393cee | ||
|
|
154cc5d392 | ||
|
|
4c31b49f18 | ||
|
|
65adb6bc26 | ||
|
|
ea56cae43a | ||
|
|
f424856685 | ||
|
|
22d28a7b18 | ||
|
|
a15f032b32 | ||
|
|
fce486553b | ||
|
|
96d0febd0e | ||
|
|
dddc52a668 | ||
|
|
65fb188586 | ||
|
|
15a5910695 | ||
|
|
6585d450c0 | ||
|
|
1af32ebf8f | ||
|
|
5f2e548ba6 | ||
|
|
3b8eb44490 | ||
|
|
8b067765e9 | ||
|
|
26ce538c45 | ||
|
|
a5cbba3de7 | ||
|
|
b5af86ddae | ||
|
|
800ceaebfe | ||
|
|
3663b974c1 | ||
|
|
d381389fc9 | ||
|
|
d3b048e933 | ||
|
|
4cf90820ba | ||
|
|
7fe47d2bbc | ||
|
|
76afdefdc7 | ||
|
|
c878619484 | ||
|
|
cfdeda68dd | ||
|
|
3354370041 | ||
|
|
be67a35e03 | ||
|
|
cbcf0be1a2 | ||
|
|
18e4768739 | ||
|
|
072db266be | ||
|
|
5ad76cb3a7 | ||
|
|
96c33b1233 | ||
|
|
7085690e3d | ||
|
|
7f430d3a45 | ||
|
|
8da8a1ad6b | ||
|
|
9e527adb24 | ||
|
|
845fe840d4 | ||
|
|
31e4f84f9a | ||
|
|
faded05e47 | ||
|
|
a05e4fd48f | ||
|
|
affe19beb5 | ||
|
|
eefa4f868e | ||
|
|
81d434c848 | ||
|
|
86902ca52c | ||
|
|
ca5b2a6377 | ||
|
|
a5c7ae34e3 | ||
|
|
48c73ae97b | ||
|
|
a7dfbf4af0 | ||
|
|
186f8b1829 | ||
|
|
6a62890445 | ||
|
|
6a410a28b6 | ||
|
|
94b4618420 | ||
|
|
840a0cd3b8 | ||
|
|
254b2dd8ec | ||
|
|
5166a761ec | ||
|
|
cb34e1f561 | ||
|
|
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 |
42
.github/workflows/build-docker-edge.yml
vendored
Normal file
42
.github/workflows/build-docker-edge.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log into GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.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
|
||||
ghcr.io/${{ github.repository }}:edge
|
||||
13
.github/workflows/build-docker.yml
vendored
13
.github/workflows/build-docker.yml
vendored
@@ -16,12 +16,19 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
- name: Log into Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
|
||||
- name: Log into GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Parse semver
|
||||
id: semver_parser
|
||||
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
|
||||
@@ -33,7 +40,6 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
|
||||
platforms: linux/386,linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"VERSION=${{ github.ref_name }}"
|
||||
@@ -42,3 +48,6 @@ jobs:
|
||||
axllent/mailpit:latest
|
||||
axllent/mailpit:${{ github.ref_name }}
|
||||
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
|
||||
|
||||
8
.github/workflows/close-stale-issues.yml
vendored
8
.github/workflows/close-stale-issues.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9.0.0
|
||||
with:
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
exempt-issue-labels: "enhancement,bug,javascript,docker"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 14 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
|
||||
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
|
||||
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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.46
|
||||
- uses: wangyoucao577/go-release-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -26,8 +26,8 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test ./internal/storage ./server ./internal/tools ./internal/html2text -v
|
||||
- run: go test ./internal/storage ./internal/html2text -bench=.
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/tools ./internal/html2text -v
|
||||
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
- name: Build web UI
|
||||
|
||||
209
CHANGELOG.md
209
CHANGELOG.md
@@ -2,6 +2,215 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.18.4]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Clone new Docker images to ghcr.io ([#302](https://github.com/axllent/mailpit/issues/302))
|
||||
|
||||
|
||||
## [v1.18.3]
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
|
||||
### Feature
|
||||
- iCalendar (ICS) viewer ([#298](https://github.com/axllent/mailpit/issues/298))
|
||||
|
||||
### Fix
|
||||
- Add dot stuffing for POP3 ([#300](https://github.com/axllent/mailpit/issues/300))
|
||||
|
||||
|
||||
## [v1.18.2]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
|
||||
### Fix
|
||||
- Replace invalid Windows username characters in sendmail ([#294](https://github.com/axllent/mailpit/issues/294))
|
||||
|
||||
|
||||
## [v1.18.1]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Simplify JSON HTTP responses
|
||||
|
||||
### Feature
|
||||
- Return queued Message ID in SMTP response ([#293](https://github.com/axllent/mailpit/issues/293))
|
||||
|
||||
|
||||
## [v1.18.0]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Update go-release-action
|
||||
- JSON key case-consistency for posted API data (backwards-compatible)
|
||||
- Remove function duplication - use common tools.InArray()
|
||||
- Improve tag sorting in web UI, ignore casing
|
||||
- Replace moment JS library with dayjs
|
||||
- Auto-update relative received message times
|
||||
|
||||
### Feature
|
||||
- API endpoint for sending ([#278](https://github.com/axllent/mailpit/issues/278))
|
||||
- Set tagging filters via a config file
|
||||
- Search filter support for auto-tagging
|
||||
- New search filter prefix `addressed:` includes From, To, Cc, Bcc & Reply-To
|
||||
|
||||
|
||||
## [v1.17.1]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Clearer error messages for read/write permission failures ([#281](https://github.com/axllent/mailpit/issues/281))
|
||||
|
||||
### Fix
|
||||
- Prevent error when two identical tags are added at the exact same time ([#283](https://github.com/axllent/mailpit/issues/283))
|
||||
|
||||
|
||||
## [v1.17.0]
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Auto-rotate thumbnail images based on exif data
|
||||
- Replace disintegration/imaging with kovidgoyal/imaging to fix CVE-2023-36308
|
||||
- Update API documentation regarding date/time searches & timezones
|
||||
- Move Link check & HTML check features out of beta
|
||||
- Remove deprecated --disable-html-check option
|
||||
|
||||
### Feature
|
||||
- Option to auto relay for matching recipient expression only ([#274](https://github.com/axllent/mailpit/issues/274))
|
||||
- Add UI settings screen
|
||||
|
||||
### Fix
|
||||
- Add delay to close database on fatal exit ([#280](https://github.com/axllent/mailpit/issues/280))
|
||||
|
||||
|
||||
## [v1.16.0]
|
||||
|
||||
### Chore
|
||||
- Update caniemail test database
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Switch database flag/env to `--database` / `MP_DATABASE`
|
||||
|
||||
### Feature
|
||||
- Search support for before: and after: dates ([#252](https://github.com/axllent/mailpit/issues/252))
|
||||
- Add optional tenant ID to isolate data in shared databases ([#254](https://github.com/axllent/mailpit/issues/254))
|
||||
- Option to use rqlite database storage ([#254](https://github.com/axllent/mailpit/issues/254))
|
||||
|
||||
### Fix
|
||||
- Remove duplicated authentication check ([#276](https://github.com/axllent/mailpit/issues/276))
|
||||
- Prevent conditional JS error when global mailbox tag list is modified via auto/plus-address tagging while viewing a message
|
||||
- Extract plus addresses from email addresses only, not names
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
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"]
|
||||
|
||||
30
README.md
30
README.md
@@ -32,30 +32,24 @@ Mailpit was originally **inspired** by MailHog which is [no longer maintained](h
|
||||
|
||||
## 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
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- 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
|
||||
|
||||
@@ -49,9 +49,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
return nil
|
||||
}
|
||||
|
||||
info.ModTime()
|
||||
|
||||
if ingestRecent > 0 && time.Now().Sub(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
|
||||
if ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
76
cmd/readyz.go
Normal file
76
cmd/readyz.go
Normal file
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
// do not verify TLS in case this instance is using HTTPS
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
|
||||
}
|
||||
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)")
|
||||
}
|
||||
@@ -19,7 +19,7 @@ If you have several thousand messages in your mailbox, then it is advised to shu
|
||||
Mailpit while you reindex as this process will likely result in database locking issues.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config.DataFile = args[0]
|
||||
config.Database = args[0]
|
||||
config.MaxMessages = 0
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
|
||||
230
cmd/root.go
230
cmd/root.go
@@ -10,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",
|
||||
@@ -35,14 +34,15 @@ Documentation:
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := storage.InitDB(); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
logger.Log().Fatal(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go server.Listen()
|
||||
|
||||
if err := smtpd.Listen(); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
@@ -78,49 +78,68 @@ 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().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
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(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
|
||||
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.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.SMTPTLSRequired, "smtp-tls-required", config.SMTPTLSRequired, "Require TLS SMTP encryption")
|
||||
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")
|
||||
|
||||
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!)")
|
||||
// SMTP relay
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP relay configuration file to allow releasing messages")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
|
||||
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
|
||||
|
||||
// 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.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
|
||||
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
|
||||
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
|
||||
|
||||
// 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)")
|
||||
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
|
||||
rootCmd.Flags().Lookup("db-file").Hidden = true
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
|
||||
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
|
||||
|
||||
// deprecated flags 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")
|
||||
@@ -133,42 +152,90 @@ 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"
|
||||
|
||||
// DEPRECATED FLAG 2024/04/13 - no longer used
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
|
||||
rootCmd.Flags().Lookup("disable-html-check").Hidden = true
|
||||
}
|
||||
|
||||
// Load settings from environment
|
||||
func initConfigFromEnv() {
|
||||
// inherit from environment if provided
|
||||
config.DataFile = os.Getenv("MP_DATA_FILE")
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
|
||||
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
|
||||
// General
|
||||
if len(os.Getenv("MP_DATABASE")) > 0 {
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
}
|
||||
|
||||
config.TenantID = os.Getenv("MP_TENANT_ID")
|
||||
|
||||
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_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 getEnabledFromEnv("MP_SMTP_TLS_REQUIRED") {
|
||||
config.SMTPTLSRequired = true
|
||||
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
|
||||
}
|
||||
@@ -185,11 +252,41 @@ func initConfigFromEnv() {
|
||||
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.SMTPRelayAll = true
|
||||
}
|
||||
config.SMTPRelayMatching = os.Getenv("MP_SMTP_RELAY_MATCHING")
|
||||
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
|
||||
config.CLITagsArg = os.Getenv("MP_TAG")
|
||||
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
|
||||
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
@@ -198,45 +295,15 @@ 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 len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
|
||||
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
|
||||
}
|
||||
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||
config.AllowUntrustedTLS = true
|
||||
}
|
||||
if len(os.Getenv("MP_LOG_FILE")) > 0 {
|
||||
logger.LogFile = os.Getenv("MP_LOG_FILE")
|
||||
}
|
||||
if getEnabledFromEnv("MP_QUIET") {
|
||||
logger.QuietLogging = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_VERBOSE") {
|
||||
logger.VerboseLogging = true
|
||||
}
|
||||
}
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
config.Database = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
@@ -262,6 +329,15 @@ func initDeprecatedConfigFromEnv() {
|
||||
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
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
|
||||
logger.Log().Warn("ENV MP_DISABLE_HTML_CHECK has been deprecated and is no longer used")
|
||||
config.DisableHTMLCheck = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to get a boolean from an environment variable
|
||||
|
||||
309
config/config.go
309
config/config.go
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -14,7 +15,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -25,8 +25,12 @@ var (
|
||||
// HTTPListen to listen on <interface>:<port>
|
||||
HTTPListen = "[::]:8025"
|
||||
|
||||
// DataFile for mail (optional)
|
||||
DataFile string
|
||||
// Database for mail (optional)
|
||||
Database string
|
||||
|
||||
// TenantID is an optional prefix to be applied to all database tables,
|
||||
// allowing multiple isolated instances of Mailpit to share a database.
|
||||
TenantID = ""
|
||||
|
||||
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
|
||||
MaxMessages = 500
|
||||
@@ -52,10 +56,14 @@ var (
|
||||
// SMTPTLSKey file
|
||||
SMTPTLSKey string
|
||||
|
||||
// SMTPTLSRequired to enforce TLS
|
||||
// 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.
|
||||
SMTPTLSRequired bool
|
||||
SMTPRequireSTARTTLS bool
|
||||
|
||||
// SMTPRequireTLS to allow only SSL/TLS connections for all connections
|
||||
//
|
||||
SMTPRequireTLS bool
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
@@ -74,26 +82,26 @@ var (
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// SMTPCLITags is used to map the CLI args
|
||||
SMTPCLITags string
|
||||
// CLITagsArg is used to map the CLI args
|
||||
CLITagsArg 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
|
||||
// TagsConfig is a yaml file to pre-load tags
|
||||
TagsConfig string
|
||||
|
||||
// TagFilters are used to apply tags to new mail
|
||||
TagFilters []autoTag
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
|
||||
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
|
||||
@@ -108,9 +116,27 @@ var (
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// SMTPRelayAll is whether to relay all incoming messages via pre-configured SMTP server.
|
||||
// Use with extreme caution!
|
||||
SMTPRelayAllIncoming = false
|
||||
SMTPRelayAll = false
|
||||
|
||||
// SMTPRelayMatching if set, will auto-release to recipients matching this regular expression
|
||||
SMTPRelayMatching string
|
||||
|
||||
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
|
||||
SMTPRelayMatchingRegexp *regexp.Regexp
|
||||
|
||||
// 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
|
||||
@@ -132,27 +158,32 @@ var (
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
)
|
||||
|
||||
// AutoTag struct for auto-tagging
|
||||
type AutoTag struct {
|
||||
Tag string
|
||||
type autoTag struct {
|
||||
Match string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -166,8 +197,18 @@ func VerifyConfig() error {
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
|
||||
if DataFile != "" && isDir(DataFile) {
|
||||
DataFile = filepath.Join(DataFile, "mailpit.db")
|
||||
if Database != "" && isDir(Database) {
|
||||
Database = filepath.Join(Database, "mailpit.db")
|
||||
}
|
||||
|
||||
TenantID = strings.TrimSpace(TenantID)
|
||||
if TenantID != "" {
|
||||
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
TenantID = re.ReplaceAllString(TenantID, "_")
|
||||
if !strings.HasSuffix(TenantID, "_") {
|
||||
TenantID = TenantID + "_"
|
||||
}
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`.*:\d+$`)
|
||||
@@ -179,13 +220,17 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
if !isFile(UIAuthFile) {
|
||||
return fmt.Errorf("[ui] HTTP password file not found: %s", UIAuthFile)
|
||||
return fmt.Errorf("[ui] HTTP password file not found or readable: %s", UIAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(UIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetUIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -196,12 +241,15 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if UITLSCert != "" {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("[ui] TLS certificate not found: %s", UITLSCert)
|
||||
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
|
||||
}
|
||||
|
||||
if !isFile(UITLSKey) {
|
||||
return fmt.Errorf("[ui] TLS key not found: %s", UITLSKey)
|
||||
return fmt.Errorf("[ui] TLS key not found or readable: %s", UITLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,24 +258,33 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
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 or readable: %s", SMTPTLSCert)
|
||||
}
|
||||
|
||||
if !isFile(SMTPTLSKey) {
|
||||
return fmt.Errorf("[smtp] TLS key not found: %s", SMTPTLSKey)
|
||||
return fmt.Errorf("[smtp] TLS key not found or readable: %s", SMTPTLSKey)
|
||||
}
|
||||
} else if SMTPTLSRequired {
|
||||
} 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 SMTPTLSRequired && SMTPAuthAllowInsecure {
|
||||
return errors.New("[smtp] TLS cannot be required while also allowing insecure authentication")
|
||||
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 or readable: %s", SMTPAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SMTPAuthFile)
|
||||
@@ -238,6 +295,18 @@ 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 {
|
||||
@@ -245,9 +314,49 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
|
||||
if !isFile(POP3TLSCert) {
|
||||
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
|
||||
}
|
||||
|
||||
if !isFile(POP3TLSKey) {
|
||||
return fmt.Errorf("[pop3] TLS key not found or readable: %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 or readable: %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)
|
||||
@@ -260,6 +369,11 @@ func VerifyConfig() error {
|
||||
return fmt.Errorf("webhook URL does not appear to be a valid URL (%s)", WebhookURL)
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/04/13
|
||||
if DisableHTMLCheck {
|
||||
logger.Log().Warn("--disable-html-check has been deprecated and is no longer used")
|
||||
}
|
||||
|
||||
if EnableSpamAssassin != "" {
|
||||
spamassassin.SetService(EnableSpamAssassin)
|
||||
logger.Log().Infof("[spamassassin] enabled via %s", EnableSpamAssassin)
|
||||
@@ -269,27 +383,13 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
SMTPTags = []AutoTag{}
|
||||
|
||||
if SMTPCLITags != "" {
|
||||
args := tools.ArgsParser(SMTPCLITags)
|
||||
|
||||
for _, a := range args {
|
||||
t := strings.Split(a, "=")
|
||||
if len(t) > 1 {
|
||||
tag := tools.CleanTag(t[0])
|
||||
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
|
||||
return fmt.Errorf("[tag] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
|
||||
}
|
||||
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
|
||||
if len(match) == 0 {
|
||||
return fmt.Errorf("[tag] invalid tag match (%s) - no search detected", tag)
|
||||
}
|
||||
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
|
||||
} else {
|
||||
return fmt.Errorf("[tag] error parsing tags (%s)", a)
|
||||
}
|
||||
}
|
||||
// load tag filters
|
||||
TagFilters = []autoTag{}
|
||||
if err := loadTagsFromArgs(CLITagsArg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadTagsFromConfig(TagsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPAllowedRecipients != "" {
|
||||
@@ -299,33 +399,55 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
SMTPAllowedRecipientsRegexp = restrictRegexp
|
||||
logger.Log().Infof("[smtp] only allowing recipients matching the following regexp: %s", SMTPAllowedRecipients)
|
||||
logger.Log().Infof("[smtp] only allowing recipients matching regexp: %s", SMTPAllowedRecipients)
|
||||
}
|
||||
|
||||
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ReleaseEnabled && SMTPRelayAllIncoming {
|
||||
return errors.New("[smtp] relay config must be set to relay all messages")
|
||||
// separate relay config validation to account for environment variables
|
||||
if err := validateRelayConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayAllIncoming {
|
||||
if !ReleaseEnabled && SMTPRelayAll || !ReleaseEnabled && SMTPRelayMatching != "" {
|
||||
return errors.New("[relay] a relay configuration must be set to auto-relay any messages")
|
||||
}
|
||||
|
||||
if SMTPRelayMatching != "" {
|
||||
if SMTPRelayAll {
|
||||
logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled")
|
||||
} else {
|
||||
restrictRegexp, err := regexp.Compile(SMTPRelayMatching)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayMatchingRegexp = restrictRegexp
|
||||
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
|
||||
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPRelayAll {
|
||||
// this deserves a warning
|
||||
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
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 or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
@@ -341,6 +463,23 @@ func parseRelayConfig(c string) error {
|
||||
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 {
|
||||
SMTPRelayConfig.Port = 25 // default
|
||||
}
|
||||
@@ -351,17 +490,17 @@ 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)
|
||||
@@ -371,35 +510,31 @@ func parseRelayConfig(c string) error {
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
|
||||
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
|
||||
logger.Log().Infof("[smtp] relay 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)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns if a path is a file
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns whether a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.IsDir() {
|
||||
if err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
81
config/tags.go
Normal file
81
config/tags.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type yamlTags struct {
|
||||
Filters []yamlTag `yaml:"filters"`
|
||||
}
|
||||
|
||||
type yamlTag struct {
|
||||
Match string `yaml:"match"`
|
||||
Tags string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// Load tags from a configuration from a file, if set
|
||||
func loadTagsFromConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil // not set, ignore
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[tags] %s", err.Error())
|
||||
}
|
||||
|
||||
conf := yamlTags{}
|
||||
|
||||
if err := yaml.Unmarshal(data, &conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if conf.Filters == nil {
|
||||
return fmt.Errorf("[tags] missing tag: array in %s", c)
|
||||
}
|
||||
|
||||
for _, t := range conf.Filters {
|
||||
tags := strings.Split(t.Tags, ",")
|
||||
TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTagsFromArgs(c string) error {
|
||||
if c == "" {
|
||||
return nil // not set, ignore
|
||||
}
|
||||
|
||||
args := tools.ArgsParser(c)
|
||||
|
||||
for _, a := range args {
|
||||
t := strings.Split(a, "=")
|
||||
if len(t) > 1 {
|
||||
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
|
||||
tags := strings.Split(t[0], ",")
|
||||
TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})
|
||||
} else {
|
||||
return fmt.Errorf("[tag] error parsing tags (%s)", a)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters"))
|
||||
|
||||
return nil
|
||||
}
|
||||
53
go.mod
53
go.mod
@@ -1,45 +1,47 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.20
|
||||
go 1.21.0
|
||||
|
||||
toolchain go1.22.1
|
||||
|
||||
require (
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jhillyerd/enmime v1.1.0
|
||||
github.com/klauspost/compress v1.17.5
|
||||
github.com/jhillyerd/enmime v1.2.0
|
||||
github.com/klauspost/compress v1.17.8
|
||||
github.com/kovidgoyal/imaging v1.6.3
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
github.com/mhale/smtpd v0.8.2
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
github.com/mhale/smtpd v0.8.3
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
|
||||
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.2
|
||||
github.com/vanng822/go-premailer v1.20.2
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/text v0.14.0
|
||||
github.com/vanng822/go-premailer v1.21.0
|
||||
golang.org/x/net v0.25.0
|
||||
golang.org/x/text v0.15.0
|
||||
golang.org/x/time v0.5.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.28.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cznic/ql v1.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/google/uuid v1.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
|
||||
@@ -48,23 +50,18 @@ require (
|
||||
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.6 // 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.18.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/image v0.16.0 // indirect
|
||||
golang.org/x/sys v0.20.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.41.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||
modernc.org/libc v1.50.9 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
169
go.sum
169
go.sum
@@ -1,79 +1,53 @@
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
|
||||
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
|
||||
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
|
||||
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
|
||||
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
|
||||
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
|
||||
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
|
||||
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
|
||||
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
|
||||
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
|
||||
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
|
||||
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
|
||||
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
|
||||
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
|
||||
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
|
||||
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
|
||||
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
|
||||
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
|
||||
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
|
||||
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
|
||||
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
|
||||
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
|
||||
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
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=
|
||||
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.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
|
||||
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E=
|
||||
github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
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.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
|
||||
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
|
||||
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 +57,18 @@ 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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
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.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
|
||||
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
|
||||
github.com/mhale/smtpd v0.8.3/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=
|
||||
@@ -106,12 +83,16 @@ github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/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/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
@@ -129,6 +110,7 @@ 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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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=
|
||||
@@ -136,44 +118,37 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
|
||||
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/go-premailer v1.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
|
||||
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
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/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/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
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.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -181,28 +156,34 @@ 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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
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/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-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.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
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=
|
||||
@@ -212,27 +193,29 @@ 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.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
|
||||
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
|
||||
modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
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.50.9 h1:hIWf1uz55lorXQhfoEoezdUHjxzuO6ceshET/yWjSjk=
|
||||
modernc.org/libc v1.50.9/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE=
|
||||
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/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
|
||||
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
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
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
@@ -136,12 +137,12 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
var y, n, p float32
|
||||
|
||||
for family, stats := range found.Stats {
|
||||
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
|
||||
if len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
|
||||
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
@@ -182,18 +183,6 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func inArray(n string, h []string) bool {
|
||||
n = strings.ToLower(n)
|
||||
|
||||
for _, v := range h {
|
||||
if strings.ToLower(v) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert markdown to HTML, stripping <p> & </p>
|
||||
func mdToHTML(str string) string {
|
||||
md := []byte(str)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package htmlcheck
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Platforms returns all platforms with their respective email clients
|
||||
func Platforms() (map[string][]string, error) {
|
||||
@@ -19,7 +23,7 @@ func Platforms() (map[string][]string, error) {
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
}
|
||||
if !inArray(niceFamily, c) {
|
||||
if !tools.InArray(niceFamily, c) {
|
||||
c = append(c, niceFamily)
|
||||
data[platform] = c
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -39,7 +40,7 @@ func Log() *logrus.Logger {
|
||||
}
|
||||
|
||||
if LogFile != "" {
|
||||
file, err := os.OpenFile(LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664)
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -21,10 +20,10 @@ var (
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
smtpAccepted int
|
||||
smtpAcceptedSize int
|
||||
smtpRejected int
|
||||
smtpIgnored int
|
||||
smtpAccepted float64
|
||||
smtpAcceptedSize float64
|
||||
smtpRejected float64
|
||||
smtpIgnored float64
|
||||
)
|
||||
|
||||
// AppInformation struct
|
||||
@@ -37,29 +36,29 @@ type AppInformation struct {
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize int64
|
||||
DatabaseSize float64
|
||||
// Total number of messages in the database
|
||||
Messages int
|
||||
Messages float64
|
||||
// Total number of messages in the database
|
||||
Unread int
|
||||
Unread float64
|
||||
// Tags and message totals per tag
|
||||
Tags map[string]int64
|
||||
// Runtime statistics
|
||||
RuntimeStats struct {
|
||||
// Mailpit server uptime in seconds
|
||||
Uptime int
|
||||
Uptime float64
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
// Database runtime messages deleted
|
||||
MessagesDeleted int
|
||||
MessagesDeleted float64
|
||||
// Accepted runtime SMTP messages
|
||||
SMTPAccepted int
|
||||
SMTPAccepted float64
|
||||
// Total runtime accepted messages size in bytes
|
||||
SMTPAcceptedSize int
|
||||
SMTPAcceptedSize float64
|
||||
// Rejected runtime SMTP messages
|
||||
SMTPRejected int
|
||||
SMTPRejected float64
|
||||
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
|
||||
SMTPIgnored int
|
||||
SMTPIgnored float64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +71,7 @@ func Load() AppInformation {
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
|
||||
|
||||
info.RuntimeStats.Uptime = int(time.Since(startedAt).Seconds())
|
||||
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
|
||||
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
|
||||
info.RuntimeStats.SMTPAccepted = smtpAccepted
|
||||
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
|
||||
@@ -96,16 +94,10 @@ func Load() AppInformation {
|
||||
}
|
||||
}
|
||||
|
||||
info.Database = config.DataFile
|
||||
|
||||
db, err := os.Stat(info.Database)
|
||||
if err == nil {
|
||||
info.DatabaseSize = db.Size()
|
||||
}
|
||||
|
||||
info.Database = config.Database
|
||||
info.DatabaseSize = storage.DbSize()
|
||||
info.Messages = storage.CountTotal()
|
||||
info.Unread = storage.CountUnread()
|
||||
|
||||
info.Tags = storage.GetAllTagsCount()
|
||||
|
||||
return info
|
||||
@@ -120,7 +112,7 @@ func Track() {
|
||||
func LogSMTPAccepted(size int) {
|
||||
mu.Lock()
|
||||
smtpAccepted = smtpAccepted + 1
|
||||
smtpAcceptedSize = smtpAcceptedSize + size
|
||||
smtpAcceptedSize = smtpAcceptedSize + float64(size)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
171
internal/storage/cron.go
Normal file
171
internal/storage/cron.go
Normal file
@@ -0,0 +1,171 @@
|
||||
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 float64
|
||||
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 %f%% 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(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
if err := q.QueryAndClose(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.Exec(`DELETE FROM `+tenant("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.Exec(`DELETE FROM `+tenant("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.Exec(`DELETE FROM `+tenant("mailbox")+` 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() {
|
||||
if sqlDriver == "rqlite" {
|
||||
// let rqlite handle vacuuming
|
||||
return
|
||||
}
|
||||
|
||||
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] VACUUM: %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] vacuum completed in %s", elapsed)
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
@@ -20,25 +15,22 @@ import (
|
||||
|
||||
"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"
|
||||
|
||||
// sqlite (native) - https://gitlab.com/cznic/sqlite
|
||||
// sqlite - https://gitlab.com/cznic/sqlite
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
// rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/
|
||||
_ "github.com/rqlite/gorqlite/stdlib"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
dbLastAction time.Time
|
||||
dbIsIdle bool
|
||||
dbDataDeleted bool
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
sqlDriver string
|
||||
dbLastAction time.Time
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
@@ -47,46 +39,77 @@ var (
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
p := config.DataFile
|
||||
p := config.Database
|
||||
var dsn string
|
||||
|
||||
if p == "" {
|
||||
// when no path is provided then we create a temporary file
|
||||
// which will get deleted on Close(), SIGINT or SIGTERM
|
||||
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
|
||||
dbIsTemp = true
|
||||
sqlDriver = "sqlite"
|
||||
dsn = p
|
||||
logger.Log().Debugf("[db] using temporary database: %s", p)
|
||||
} else if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
|
||||
sqlDriver = "rqlite"
|
||||
dsn = p
|
||||
logger.Log().Debugf("[db] opening rqlite database %s", p)
|
||||
} else {
|
||||
p = filepath.Clean(p)
|
||||
sqlDriver = "sqlite"
|
||||
dsn = fmt.Sprintf("file:%s?cache=shared", p)
|
||||
logger.Log().Debugf("[db] opening database %s", p)
|
||||
}
|
||||
|
||||
config.DataFile = p
|
||||
config.Database = p
|
||||
|
||||
logger.Log().Debugf("[db] opening database %s", p)
|
||||
if sqlDriver == "sqlite" {
|
||||
if !isFile(p) {
|
||||
// try create a file to ensure permissions
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[db] %s", err.Error())
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?cache=shared", p)
|
||||
|
||||
db, err = sql.Open("sqlite", dsn)
|
||||
db, err = sql.Open(sqlDriver, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 1; i < 6; i++ {
|
||||
if err := Ping(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
|
||||
time.Sleep(5 * time.Second)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// prevent "database locked" errors
|
||||
// @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
|
||||
if sqlDriver == "sqlite" {
|
||||
// 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 {
|
||||
if err := dbApplySchemas(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LoadTagFilters()
|
||||
|
||||
dbFile = p
|
||||
dbLastAction = time.Now()
|
||||
|
||||
@@ -112,14 +135,25 @@ func InitDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will close the database, and delete if a temporary table
|
||||
// Tenant applies an optional prefix to the table name
|
||||
func tenant(table string) string {
|
||||
return fmt.Sprintf("%s%s", config.TenantID, table)
|
||||
}
|
||||
|
||||
// Close will close the database, and delete if temporary
|
||||
func Close() {
|
||||
// on a fatal exit (eg: ports blocked), allow Mailpit to run migration tasks before closing the DB
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Log().Warn("[db] error closing database, ignoring")
|
||||
}
|
||||
}
|
||||
|
||||
// allow SQLite to finish closing DB & write WAL logs if local
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if dbIsTemp && isFile(dbFile) {
|
||||
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
|
||||
if err := os.Remove(dbFile); err != nil {
|
||||
@@ -128,593 +162,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().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")}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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(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 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
|
||||
|
||||
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(nil, 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 {
|
||||
// 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()
|
||||
dbDataDeleted = true
|
||||
|
||||
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(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
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
_, 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
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return err
|
||||
// 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
|
||||
@@ -735,53 +185,63 @@ func StatsGet() MailboxStats {
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() int {
|
||||
var total int
|
||||
func CountTotal() float64 {
|
||||
var total float64
|
||||
|
||||
_ = sqlf.From("mailbox").
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(nil, db)
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() int {
|
||||
var total int
|
||||
func CountUnread() float64 {
|
||||
var total float64
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() int {
|
||||
var total int
|
||||
func CountRead() float64 {
|
||||
var total float64
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From(tenant("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.
|
||||
// DbSize returns the size of the SQLite database.
|
||||
func DbSize() float64 {
|
||||
var total sql.NullFloat64
|
||||
|
||||
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
// IsUnread returns whether a message is unread or not.
|
||||
func IsUnread(id string) bool {
|
||||
var unread int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From(tenant("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
|
||||
}
|
||||
@@ -790,11 +250,10 @@ func IsUnread(id string) bool {
|
||||
func MessageIDExists(id string) bool {
|
||||
var total int
|
||||
|
||||
q := sqlf.From("mailbox").
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("MessageID = ?", id)
|
||||
|
||||
_ = q.QueryRowAndClose(nil, db)
|
||||
Where("MessageID = ?", id).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total != 0
|
||||
}
|
||||
|
||||
670
internal/storage/messages.go
Normal file
670
internal/storage/messages.go
Normal file
@@ -0,0 +1,670 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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 := float64(len(*body))
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
sql := fmt.Sprintf(`INSERT INTO %s
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
tenant("mailbox"),
|
||||
) // #nosec
|
||||
|
||||
// insert mail summary data
|
||||
_, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert compressed raw message
|
||||
encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
|
||||
hexStr := hex.EncodeToString(encoded)
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// extract tags from body matches
|
||||
rawTags := findTagsInRawMessage(body)
|
||||
// extract plus addresses tags from enmime.Envelope
|
||||
plusTags := obj.tagsFromPlusAddresses()
|
||||
// extract tags from X-Tags header
|
||||
xTags := tools.SetTagCasing(strings.Split(strings.TrimSpace(env.Root.Header.Get("X-Tags")), ","))
|
||||
// extract tags from search matches
|
||||
searchTags := tagFilterMatches(id)
|
||||
|
||||
// combine all tags into one slice
|
||||
tags := append(rawTags, plusTags...)
|
||||
tags = append(tags, xTags...)
|
||||
// sort and extract only unique tags
|
||||
tags = sortedUniqueTags(append(tags, searchTags...))
|
||||
|
||||
if len(tags) > 0 {
|
||||
if err := SetMessageTags(id, tags); 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 = tags
|
||||
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(tenant("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 float64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
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(int64(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(tenant("mailbox")).
|
||||
Select(`Created`).
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
|
||||
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(int64(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: float64(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(tenant("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")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
if sqlDriver == "rqlite" {
|
||||
data, err = base64.StdEncoding.DecodeString(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding base64 message: %w", err)
|
||||
}
|
||||
} else {
|
||||
data = []byte(msg)
|
||||
}
|
||||
|
||||
raw, err := dbDecoder.DecodeAll(data, 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, r.URL.Query().Get("tz"), 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(tenant("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(tenant("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 %v 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(tenant("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 %v 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(tenant("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
|
||||
}
|
||||
|
||||
// DeleteMessages deletes one or more messages in bulk
|
||||
func DeleteMessages(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec
|
||||
rows, err := db.Query(sql, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
toDelete := []string{}
|
||||
var totalSize float64
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var size float64
|
||||
if err := rows.Scan(&id, &size); err != nil {
|
||||
return err
|
||||
}
|
||||
toDelete = append(toDelete, id)
|
||||
totalSize = totalSize + size
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return nil // nothing to delete
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args = make([]interface{}, len(toDelete))
|
||||
for i, id := range toDelete {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
tables := []string{"mailbox", "mailbox_data", "message_tags"}
|
||||
|
||||
for _, t := range tables {
|
||||
sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1))
|
||||
|
||||
_, err = tx.Exec(sql, args...) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
|
||||
logMessagesDeleted(len(toDelete))
|
||||
|
||||
_ = pruneUnusedTags()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
messages := "messages"
|
||||
if len(toDelete) == 1 {
|
||||
messages = "message"
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed)
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages() error {
|
||||
var (
|
||||
start = time.Now()
|
||||
total int
|
||||
)
|
||||
|
||||
_ = sqlf.From(tenant("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()
|
||||
|
||||
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
|
||||
|
||||
for _, t := range tables {
|
||||
sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec
|
||||
_, err := tx.Exec(sql)
|
||||
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
|
||||
}
|
||||
@@ -13,8 +13,6 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
@@ -22,19 +20,17 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -56,19 +52,17 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
}
|
||||
@@ -107,14 +101,14 @@ func TestRetrieveMimeEmail(t *testing.T) {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
|
||||
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
@@ -1,73 +0,0 @@
|
||||
package storage
|
||||
|
||||
// These functions are used to migrate data formats/structure on startup.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
func dataMigrations() {
|
||||
migrateTagsToManyMany()
|
||||
}
|
||||
|
||||
// Migrate tags to ManyMany structure
|
||||
// Migration task implemented 12/2023
|
||||
// Can be removed end 06/2024 and Tags column & index dropped from mailbox
|
||||
func migrateTagsToManyMany() {
|
||||
toConvert := make(map[string][]string)
|
||||
q := sqlf.
|
||||
Select("ID, Tags").
|
||||
From("mailbox").
|
||||
Where("Tags != ?", "[]").
|
||||
Where("Tags IS NOT NULL")
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var id string
|
||||
var jsonTags string
|
||||
if err := row.Scan(&id, &jsonTags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tags := []string{}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
toConvert[id] = tags
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
|
||||
if len(toConvert) > 0 {
|
||||
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
|
||||
for id, tags := range toConvert {
|
||||
if err := SetMessageTags(id, tags); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
} else {
|
||||
if _, err := sqlf.Update("mailbox").
|
||||
Set("Tags", nil).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log().Info("[migration] tags conversion complete")
|
||||
}
|
||||
|
||||
// set all legacy `[]` tags to NULL
|
||||
if _, err := sqlf.Update("mailbox").
|
||||
Set("Tags", nil).
|
||||
Where("Tags = ?", "[]").
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package storage
|
||||
|
||||
import "github.com/GuiaBolso/darwin"
|
||||
|
||||
var (
|
||||
dbMigrations = []darwin.Migration{
|
||||
{
|
||||
Version: 1.0,
|
||||
Description: "Creating tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailbox (
|
||||
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
Data BLOB,
|
||||
Search TEXT,
|
||||
Read INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mailbox_data (
|
||||
ID TEXT KEY NOT NULL,
|
||||
Email BLOB
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
|
||||
},
|
||||
{
|
||||
Version: 1.1,
|
||||
Description: "Create tags column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.2,
|
||||
Description: "Creating new mailbox format",
|
||||
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
|
||||
Created INTEGER NOT NULL,
|
||||
ID TEXT NOT NULL,
|
||||
MessageID TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
Metadata TEXT,
|
||||
Size INTEGER NOT NULL,
|
||||
Inline INTEGER NOT NULL,
|
||||
Attachments INTEGER NOT NULL,
|
||||
Read INTEGER,
|
||||
Tags TEXT,
|
||||
SearchText TEXT
|
||||
);
|
||||
INSERT INTO mailboxtmp
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
|
||||
SELECT
|
||||
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
|
||||
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
|
||||
Search, Read, Tags
|
||||
FROM mailbox;
|
||||
|
||||
DROP TABLE IF EXISTS mailbox;
|
||||
ALTER TABLE mailboxtmp RENAME TO mailbox;
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
|
||||
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
|
||||
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
|
||||
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
|
||||
},
|
||||
{
|
||||
Version: 1.3,
|
||||
Description: "Create snippet column",
|
||||
Script: `ALTER TABLE mailbox ADD COLUMN Snippet Text NOT NULL DEFAULT '';`,
|
||||
},
|
||||
{
|
||||
Version: 1.4,
|
||||
Description: "Create tag tables",
|
||||
Script: `CREATE TABLE IF NOT EXISTS tags (
|
||||
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
Name TEXT COLLATE NOCASE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tag_name ON tags (Name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_tags(
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT REFERENCES mailbox(ID),
|
||||
TagID INT REFERENCES tags(ID)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tag_id ON message_tags (ID);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tag_tagid ON message_tags (TagID);`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Create tables and apply migrations if required
|
||||
func dbApplyMigrations() error {
|
||||
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
|
||||
|
||||
d := darwin.New(driver, dbMigrations, nil)
|
||||
|
||||
return d.Migrate()
|
||||
}
|
||||
@@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total int
|
||||
Unread int
|
||||
Total float64
|
||||
Unread float64
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -22,9 +25,9 @@ func ReindexAll() {
|
||||
finished := 0
|
||||
|
||||
err := sqlf.Select("ID").To(&i).
|
||||
From("mailbox").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
ids = append(ids, i)
|
||||
})
|
||||
|
||||
@@ -43,6 +46,7 @@ func ReindexAll() {
|
||||
ID string
|
||||
SearchText string
|
||||
Snippet string
|
||||
Metadata string
|
||||
}
|
||||
|
||||
for _, ids := range chunks {
|
||||
@@ -63,6 +67,28 @@ func ReindexAll() {
|
||||
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
|
||||
}
|
||||
|
||||
searchText := createSearchText(env)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
@@ -70,6 +96,7 @@ func ReindexAll() {
|
||||
u.ID = id
|
||||
u.SearchText = searchText
|
||||
u.Snippet = snippet
|
||||
u.Metadata = string(MetadataJSON)
|
||||
|
||||
updates = append(updates, u)
|
||||
}
|
||||
@@ -86,7 +113,7 @@ 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(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
|
||||
222
internal/storage/schemas.go
Normal file
222
internal/storage/schemas.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
//go:embed schemas/*
|
||||
var schemaScripts embed.FS
|
||||
|
||||
// Create tables and apply schemas if required
|
||||
func dbApplySchemas() error {
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var legacyMigrationTable int
|
||||
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if legacyMigrationTable == 1 {
|
||||
rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacySchemas := []string{}
|
||||
|
||||
for rows.Next() {
|
||||
var oldID string
|
||||
if err := rows.Scan(&oldID); err == nil {
|
||||
legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID))
|
||||
}
|
||||
}
|
||||
|
||||
legacySchemas = semver.SortMin(legacySchemas)
|
||||
|
||||
for _, v := range legacySchemas {
|
||||
var migrated int
|
||||
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if migrated == 0 {
|
||||
// copy to tenant("schemas")
|
||||
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete legacy migration database after 01/10/2024
|
||||
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
|
||||
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schemaFiles, err := schemaScripts.ReadDir("schemas")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
temp := template.New("")
|
||||
temp.Funcs(
|
||||
template.FuncMap{
|
||||
"tenant": tenant,
|
||||
},
|
||||
)
|
||||
|
||||
type schema struct {
|
||||
Name string
|
||||
Semver string
|
||||
}
|
||||
|
||||
scripts := []schema{}
|
||||
|
||||
for _, s := range schemaFiles {
|
||||
if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
schemaID := strings.TrimRight(s.Name(), ".sql")
|
||||
|
||||
if !semver.IsValid(schemaID) {
|
||||
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)}
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
|
||||
// sort schemas by semver, low to high
|
||||
sort.Slice(scripts, func(i, j int) bool {
|
||||
return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1
|
||||
})
|
||||
|
||||
for _, s := range scripts {
|
||||
var complete int
|
||||
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if complete == 1 {
|
||||
// already completed, ignore
|
||||
continue
|
||||
}
|
||||
// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305
|
||||
b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse import script
|
||||
t1, err := temp.Parse(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
err = t1.Execute(buf, nil)
|
||||
|
||||
if _, err := db.Exec(buf.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] applied schema: %s", s.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// TODO: 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(tenant("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(tenant("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(tenant("mailbox")).
|
||||
Set("Tags", nil).
|
||||
Where("Tags = ?", "[]").
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
logger.Log().Errorf("[migration] %s", err.Error())
|
||||
}
|
||||
}
|
||||
19
internal/storage/schemas/1.0.0.sql
Normal file
19
internal/storage/schemas/1.0.0.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- CREATE TABLES
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} (
|
||||
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
Data BLOB,
|
||||
Search TEXT,
|
||||
Read INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} (
|
||||
ID TEXT KEY NOT NULL,
|
||||
Email BLOB
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID);
|
||||
3
internal/storage/schemas/1.1.0.sql
Normal file
3
internal/storage/schemas/1.1.0.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- CREATE TAGS COLUMN
|
||||
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);
|
||||
36
internal/storage/schemas/1.2.0.sql
Normal file
36
internal/storage/schemas/1.2.0.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- CREATING NEW MAILBOX FORMAT
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "mailboxtmp" }} (
|
||||
Created INTEGER NOT NULL,
|
||||
ID TEXT NOT NULL,
|
||||
MessageID TEXT NOT NULL,
|
||||
Subject TEXT NOT NULL,
|
||||
Metadata TEXT,
|
||||
Size INTEGER NOT NULL,
|
||||
Inline INTEGER NOT NULL,
|
||||
Attachments INTEGER NOT NULL,
|
||||
Read INTEGER,
|
||||
Tags TEXT,
|
||||
SearchText TEXT
|
||||
);
|
||||
|
||||
INSERT INTO {{ tenant "mailboxtmp" }}
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
|
||||
SELECT
|
||||
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
|
||||
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
|
||||
Search, Read, Tags
|
||||
FROM {{ tenant "mailbox" }};
|
||||
|
||||
DROP TABLE IF EXISTS {{ tenant "mailbox" }};
|
||||
|
||||
ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }};
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);
|
||||
2
internal/storage/schemas/1.3.0.sql
Normal file
2
internal/storage/schemas/1.3.0.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- CREATE SNIPPET COLUMN
|
||||
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT '';
|
||||
16
internal/storage/schemas/1.4.0.sql
Normal file
16
internal/storage/schemas/1.4.0.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- CREATE TAG TABLES
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} (
|
||||
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
Name TEXT COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT REFERENCES {{ tenant "mailbox" }} (ID),
|
||||
TagID INT REFERENCES {{ tenant "tags" }} (ID)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID);
|
||||
7
internal/storage/schemas/1.5.0.sql
Normal file
7
internal/storage/schemas/1.5.0.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- CREATE SETTINGS TABLE
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} (
|
||||
Key TEXT,
|
||||
Value TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key);
|
||||
INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }}));
|
||||
5
internal/storage/schemas/README.md
Normal file
5
internal/storage/schemas/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Migration scripts
|
||||
|
||||
- Scripts should be named using semver and have the `.sql` extension.
|
||||
- Inline comments should be prefixed with a `--`
|
||||
- All references to tables and indexes should be wrapped with a `{{ tenant "<name>" }}`
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
|
||||
results := []MessageSummary{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
@@ -26,23 +27,23 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search)
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var size float64
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
var ignore string
|
||||
em := MessageSummary{}
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -52,7 +53,7 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
return
|
||||
}
|
||||
|
||||
em.Created = time.UnixMilli(created)
|
||||
em.Created = time.UnixMilli(int64(created))
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
@@ -95,30 +96,31 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func DeleteSearch(search string) error {
|
||||
q := searchQueryBuilder(search)
|
||||
func DeleteSearch(search, timezone string) error {
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
ids := []string{}
|
||||
deleteSize := float64(0)
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
var created int64
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size int
|
||||
var size float64
|
||||
var attachments int
|
||||
// var tags string
|
||||
var read int
|
||||
var snippet string
|
||||
var ignore string
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -158,21 +160,21 @@ func DeleteSearch(search string) error {
|
||||
delIDs[i] = id
|
||||
}
|
||||
|
||||
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
sqlDelete1 := `DELETE FROM ` + tenant("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 ` + tenant("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) + `)`
|
||||
sqlDelete3 := `DELETE FROM ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
|
||||
|
||||
_, err = tx.Exec(sqlDelete3, delIDs...)
|
||||
if err != nil {
|
||||
@@ -191,7 +193,7 @@ func DeleteSearch(search string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
dbDataDeleted = true
|
||||
addDeletedSize(int64(deleteSize))
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
@@ -202,17 +204,27 @@ 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 {
|
||||
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
// group strings with quotes as a single argument and remove quotes
|
||||
args := tools.ArgsParser(searchString)
|
||||
|
||||
q := sqlf.From("mailbox m").
|
||||
if timezone != "" {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
|
||||
} else {
|
||||
time.Local = loc
|
||||
}
|
||||
}
|
||||
|
||||
q := sqlf.From(tenant("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
|
||||
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,
|
||||
IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON
|
||||
`).
|
||||
OrderBy("m.Created DESC")
|
||||
|
||||
@@ -273,6 +285,25 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
|
||||
}
|
||||
}
|
||||
} 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, "addressed:") {
|
||||
w = cleanString(w[10:])
|
||||
arg := "%" + escPercentChar(w) + "%"
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg)
|
||||
} else {
|
||||
q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "subject:") {
|
||||
w = w[8:]
|
||||
if w != "" {
|
||||
@@ -295,9 +326,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
w = cleanString(w[4:])
|
||||
if w != "" {
|
||||
if exclude {
|
||||
q.Where(`m.ID NOT IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
q.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
q.Where(`m.ID IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
|
||||
}
|
||||
}
|
||||
} else if lw == "is:read" {
|
||||
@@ -314,9 +345,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
}
|
||||
} else if lw == "is:tagged" {
|
||||
if exclude {
|
||||
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
} else {
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
|
||||
}
|
||||
} else if lw == "has:attachment" || lw == "has:attachments" {
|
||||
if exclude {
|
||||
@@ -324,6 +355,36 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
|
||||
} else {
|
||||
q.Where("Attachments > 0")
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "after:") {
|
||||
w = cleanString(w[6:])
|
||||
if w != "" {
|
||||
t, err := dateparse.ParseLocal(w)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
|
||||
} else {
|
||||
timestamp := t.UnixMilli()
|
||||
if exclude {
|
||||
q.Where(`m.Created <= ?`, timestamp)
|
||||
} else {
|
||||
q.Where(`m.Created >= ?`, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "before:") {
|
||||
w = cleanString(w[7:])
|
||||
if w != "" {
|
||||
t, err := dateparse.ParseLocal(w)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
|
||||
} else {
|
||||
timestamp := t.UnixMilli()
|
||||
if exclude {
|
||||
q.Where(`m.Created >= ?`, timestamp)
|
||||
} else {
|
||||
q.Where(`m.Created <= ?`, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
|
||||
@@ -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 {
|
||||
@@ -44,26 +48,34 @@ 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))
|
||||
|
||||
summaries, _, err := Search(search, 0, 100)
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "1 search result expected")
|
||||
assertEqual(t, 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")
|
||||
@@ -73,7 +85,7 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", 0, testRuns)
|
||||
summaries, _, err := Search("This is the email body", "", 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -97,7 +109,7 @@ func TestSearchDelete100(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -105,12 +117,12 @@ func TestSearchDelete100(t *testing.T) {
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -131,7 +143,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", 0, 100)
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -139,12 +151,12 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
assertEqual(t, total, 1100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com"); err != nil {
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
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(tenant("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 `+tenant("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() float64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("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.Float64
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() float64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("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.Float64
|
||||
}
|
||||
|
||||
// AddDeletedSize will add the value to the DeletedSize setting
|
||||
func addDeletedSize(v int64) {
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type Message struct {
|
||||
// Message body HTML
|
||||
HTML string
|
||||
// Message size in bytes
|
||||
Size int
|
||||
Size float64
|
||||
// Inline message attachments
|
||||
Inline []Attachment
|
||||
// Message attachments
|
||||
@@ -61,7 +61,7 @@ type Attachment struct {
|
||||
// Content ID
|
||||
ContentID string
|
||||
// Size in bytes
|
||||
Size int
|
||||
Size float64
|
||||
}
|
||||
|
||||
// MessageSummary struct for frontend messages
|
||||
@@ -82,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
|
||||
@@ -89,7 +91,7 @@ type MessageSummary struct {
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message size in bytes (total)
|
||||
Size int
|
||||
Size float64
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
// Message snippet includes up to 250 characters
|
||||
@@ -98,17 +100,18 @@ type MessageSummary struct {
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total int
|
||||
Unread int
|
||||
Total float64
|
||||
Unread float64
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -121,7 +124,7 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = len(a.Content)
|
||||
o.Size = float64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
84
internal/storage/tagfilters.go
Normal file
84
internal/storage/tagfilters.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// TagFilter struct
|
||||
type TagFilter struct {
|
||||
Match string
|
||||
SQL *sqlf.Stmt
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var tagFilters = []TagFilter{}
|
||||
|
||||
// LoadTagFilters loads tag filters from the config and pre-generates the SQL query
|
||||
func LoadTagFilters() {
|
||||
tagFilters = []TagFilter{}
|
||||
|
||||
for _, t := range config.TagFilters {
|
||||
match := strings.TrimSpace(t.Match)
|
||||
if match == "" {
|
||||
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
|
||||
continue
|
||||
}
|
||||
if t.Tags == nil || len(t.Tags) == 0 {
|
||||
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
|
||||
continue
|
||||
}
|
||||
|
||||
validTags := []string{}
|
||||
for _, tag := range t.Tags {
|
||||
tagName := tools.CleanTag(tag)
|
||||
if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {
|
||||
logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName)
|
||||
continue
|
||||
}
|
||||
validTags = append(validTags, tagName)
|
||||
}
|
||||
|
||||
if len(validTags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")})
|
||||
}
|
||||
}
|
||||
|
||||
// TagFilterMatches returns a slice of matching tags from a message
|
||||
func tagFilterMatches(id string) []string {
|
||||
tags := []string{}
|
||||
|
||||
if len(tagFilters) == 0 {
|
||||
return tags
|
||||
}
|
||||
|
||||
for _, f := range tagFilters {
|
||||
var matchID string
|
||||
q := f.SQL.Clone().Where("ID = ?", id)
|
||||
if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {
|
||||
var ignore sql.NullString
|
||||
|
||||
if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return tags
|
||||
}
|
||||
if matchID == id {
|
||||
tags = append(tags, f.Tags...)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
@@ -11,12 +15,17 @@ import (
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
// SetMessageTags will set the tags for a given database ID
|
||||
var (
|
||||
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
|
||||
addTagMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// SetMessageTags will set the tags for a given database ID, removing any not in the array
|
||||
func SetMessageTags(id string, tags []string) error {
|
||||
applyTags := []string{}
|
||||
for _, t := range tags {
|
||||
t = tools.CleanTag(t)
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
|
||||
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
|
||||
applyTags = append(applyTags, t)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +34,7 @@ func SetMessageTags(id string, tags []string) error {
|
||||
origTagCount := len(currentTags)
|
||||
|
||||
for _, t := range applyTags {
|
||||
t = tools.CleanTag(t)
|
||||
if t == "" || !config.ValidTagRegexp.MatchString(t) || inArray(t, currentTags) {
|
||||
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -39,7 +47,7 @@ func SetMessageTags(id string, tags []string) error {
|
||||
currentTags = getMessageTags(id)
|
||||
|
||||
for _, t := range currentTags {
|
||||
if !inArray(t, applyTags) {
|
||||
if !tools.InArray(t, applyTags) {
|
||||
if err := DeleteMessageTag(id, t); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -52,74 +60,62 @@ func SetMessageTags(id string, tags []string) error {
|
||||
|
||||
// AddMessageTag adds a tag to a message
|
||||
func AddMessageTag(id, name string) error {
|
||||
// prevent two identical tags being added at the same time
|
||||
addTagMutex.Lock()
|
||||
|
||||
var tagID int
|
||||
|
||||
q := sqlf.From("tags").
|
||||
q := sqlf.From(tenant("tags")).
|
||||
Select("ID").To(&tagID).
|
||||
Where("Name = ?", name)
|
||||
|
||||
// tag exists - add tag to message
|
||||
if err := q.QueryRowAndClose(nil, db); err == nil {
|
||||
// if tag exists - add tag to message
|
||||
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
|
||||
addTagMutex.Unlock()
|
||||
// check message does not already have this tag
|
||||
var count int
|
||||
if _, err := sqlf.From("message_tags").
|
||||
|
||||
if err := sqlf.From(tenant("message_tags")).
|
||||
Select("COUNT(ID)").To(&count).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
QueryRowAndClose(context.Background(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
if count > 0 {
|
||||
// already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
_, err := sqlf.InsertInto("message_tags").
|
||||
_, err := sqlf.InsertInto(tenant("message_tags")).
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(nil, db)
|
||||
ExecAndClose(context.TODO(), db)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
|
||||
|
||||
// tag dos not exist, add new one
|
||||
if err := sqlf.InsertInto("tags").
|
||||
// new tag, add to the database
|
||||
if _, err := sqlf.InsertInto(tenant("tags")).
|
||||
Set("Name", name).
|
||||
Returning("ID").To(&tagID).
|
||||
QueryRowAndClose(nil, db); err != nil {
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
addTagMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// check message does not already have this tag
|
||||
var count int
|
||||
if _, err := sqlf.From("message_tags").
|
||||
Select("COUNT(ID)").To(&count).
|
||||
Where("ID = ?", id).
|
||||
Where("TagID = ?", tagID).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return nil // already exists
|
||||
}
|
||||
addTagMutex.Unlock()
|
||||
|
||||
// add tag to message
|
||||
_, err := sqlf.InsertInto("message_tags").
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(nil, db)
|
||||
return err
|
||||
// add tag to the message
|
||||
return AddMessageTag(id, name)
|
||||
}
|
||||
|
||||
// DeleteMessageTag deleted a tag from a message
|
||||
func DeleteMessageTag(id, name string) error {
|
||||
if _, err := sqlf.DeleteFrom("message_tags").
|
||||
Where("message_tags.ID = ?", id).
|
||||
Where(`message_tags.Key IN (SELECT Key FROM message_tags LEFT JOIN tags ON TagID=tags.ID WHERE Name = ?)`, name).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -128,9 +124,9 @@ func DeleteMessageTag(id, name string) error {
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func DeleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom("message_tags").
|
||||
Where("message_tags.ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -144,9 +140,9 @@ func GetAllTags() []string {
|
||||
|
||||
if err := sqlf.
|
||||
Select(`DISTINCT Name`).
|
||||
From("tags").To(&name).
|
||||
From(tenant("tags")).To(&name).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -163,12 +159,12 @@ func GetAllTagsCount() map[string]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").
|
||||
Select(`COUNT(`+tenant("message_tags.TagID")+`) as total`).To(&total).
|
||||
From(tenant("tags")).
|
||||
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
|
||||
GroupBy(tenant("message_tags.TagID")).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
// tags = append(tags, name)
|
||||
}); err != nil {
|
||||
@@ -180,14 +176,14 @@ func GetAllTagsCount() map[string]int64 {
|
||||
|
||||
// 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")
|
||||
q := sqlf.From(tenant("tags")).
|
||||
Select(tenant("tags.ID")+", "+tenant("tags.Name")+", COUNT("+tenant("message_tags.ID")+") as COUNT").
|
||||
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
|
||||
GroupBy(tenant("tags.ID"))
|
||||
|
||||
toDel := []int{}
|
||||
|
||||
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var n string
|
||||
var id int
|
||||
var c int
|
||||
@@ -207,9 +203,9 @@ func pruneUnusedTags() error {
|
||||
|
||||
if len(toDel) > 0 {
|
||||
for _, id := range toDel {
|
||||
if _, err := sqlf.DeleteFrom("tags").
|
||||
if _, err := sqlf.DeleteFrom(tenant("tags")).
|
||||
Where("ID = ?", id).
|
||||
ExecAndClose(nil, db); err != nil {
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -218,22 +214,53 @@ func pruneUnusedTags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find tags set via --tags in raw message.
|
||||
// Find tags set via --tags in raw message, useful for matching all headers etc.
|
||||
// This function is largely superseded by the database searching, however this
|
||||
// includes literally everything and is kept for backwards compatibility.
|
||||
// Returns a comma-separated string.
|
||||
func findTagsInRawMessage(message *[]byte) string {
|
||||
tagStr := ""
|
||||
if len(config.SMTPTags) == 0 {
|
||||
return tagStr
|
||||
func findTagsInRawMessage(message *[]byte) []string {
|
||||
tags := []string{}
|
||||
if len(tagFilters) == 0 {
|
||||
return tags
|
||||
}
|
||||
|
||||
str := strings.ToLower(string(*message))
|
||||
for _, t := range config.SMTPTags {
|
||||
if strings.Contains(str, t.Match) {
|
||||
tagStr += "," + t.Tag
|
||||
str := bytes.ToLower(*message)
|
||||
for _, t := range tagFilters {
|
||||
if bytes.Contains(str, []byte(t.Match)) {
|
||||
tags = append(tags, t.Tags...)
|
||||
}
|
||||
}
|
||||
|
||||
return tagStr
|
||||
return tags
|
||||
}
|
||||
|
||||
// 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.Address, 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
for _, c := range d.Cc {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
for _, c := range d.Bcc {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
}
|
||||
matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
|
||||
if len(matches) == 1 {
|
||||
tags = append(tags, strings.Split(matches[0][2], "+")...)
|
||||
}
|
||||
|
||||
return tools.SetTagCasing(tags)
|
||||
}
|
||||
|
||||
// Get message tags from the database for a given database ID
|
||||
@@ -244,11 +271,11 @@ func getMessageTags(id string) []string {
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
From("Tags").
|
||||
LeftJoin("message_tags", "Tags.ID=message_tags.TagID").
|
||||
Where(`message_tags.ID = ?`, id).
|
||||
From(tenant("Tags")).
|
||||
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
|
||||
Where(tenant("message_tags.ID")+` = ?`, id).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(nil, db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
@@ -258,24 +285,27 @@ func getMessageTags(id string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
|
||||
func uniqueTagsFromString(s string) []string {
|
||||
// SortedUniqueTags will return a unique slice of normalised tags
|
||||
func sortedUniqueTags(s []string) []string {
|
||||
tags := []string{}
|
||||
added := make(map[string]bool)
|
||||
|
||||
if s == "" {
|
||||
if len(s) == 0 {
|
||||
return tags
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
for _, p := range parts {
|
||||
for _, p := range s {
|
||||
w := tools.CleanTag(p)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
lc := strings.ToLower(w)
|
||||
if _, exists := added[lc]; exists {
|
||||
continue
|
||||
}
|
||||
if config.ValidTagRegexp.MatchString(w) {
|
||||
if !inArray(w, tags) {
|
||||
tags = append(tags, w)
|
||||
}
|
||||
added[lc] = true
|
||||
tags = append(tags, w)
|
||||
} else {
|
||||
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
|
||||
}
|
||||
|
||||
@@ -107,5 +107,24 @@ func TestTags(t *testing.T) {
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Dirty message tag did not clean as expected")
|
||||
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
|
||||
)
|
||||
@@ -18,7 +19,7 @@ var (
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
@@ -26,11 +27,21 @@ func setup() {
|
||||
|
||||
var err error
|
||||
|
||||
// ensure DB is empty
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -47,11 +58,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if total != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
|
||||
if float64(total) != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
|
||||
if float64(unread) != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/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
|
||||
StatsDeleted float64
|
||||
)
|
||||
|
||||
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
||||
@@ -77,111 +70,10 @@ 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.Query(`DELETE FROM message_tags WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[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())
|
||||
}
|
||||
|
||||
dbDataDeleted = true
|
||||
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
|
||||
|
||||
logMessagesDeleted(len(ids))
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogMessagesDeleted logs the number of messages deleted
|
||||
func logMessagesDeleted(n int) {
|
||||
mu.Lock()
|
||||
StatsDeleted = StatsDeleted + n
|
||||
StatsDeleted = StatsDeleted + float64(n)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -195,58 +87,7 @@ func isFile(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// InArray tests if a string in within an array. It is not case sensitive.
|
||||
func inArray(k string, arr []string) bool {
|
||||
k = strings.ToLower(k)
|
||||
for _, v := range arr {
|
||||
if strings.ToLower(v) == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -3,23 +3,45 @@ 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
|
||||
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
|
||||
func CleanTag(s string) string {
|
||||
s = strings.TrimSpace(
|
||||
return strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
||||
// SetTagCasing returns the slice of tags, title-casing if set
|
||||
func SetTagCasing(s []string) []string {
|
||||
if !TagsTitleCase {
|
||||
return s
|
||||
}
|
||||
|
||||
titleTags := []string{}
|
||||
|
||||
c := cases.Title(language.Und, cases.NoLower)
|
||||
|
||||
for _, t := range s {
|
||||
titleTags = append(titleTags, c.String(t))
|
||||
}
|
||||
|
||||
return titleTags
|
||||
}
|
||||
|
||||
26
internal/tools/utils.go
Normal file
26
internal/tools/utils.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Plural returns a singular or plural of a word together with the total
|
||||
func Plural(total int, singular, plural string) string {
|
||||
if total == 1 {
|
||||
return fmt.Sprintf("%d %s", total, singular)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", total, plural)
|
||||
}
|
||||
|
||||
// InArray 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 {
|
||||
if strings.ToLower(v) == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -335,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)
|
||||
|
||||
1640
package-lock.json
generated
1640
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,12 @@
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"ical.js": "^2.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"moment": "^2.29.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"timezones-list": "^3.0.3",
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0",
|
||||
"vue-router": "^4.2.4"
|
||||
@@ -27,7 +29,7 @@
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild": "^0.21.3",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
@@ -42,15 +43,19 @@ var (
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ensure only valid characters are used, ie: windows
|
||||
re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`)
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
host = "localhost"
|
||||
} else {
|
||||
host = re.ReplaceAllString(host, "-")
|
||||
}
|
||||
|
||||
username := "nobody"
|
||||
user, err := user.Current()
|
||||
if err == nil && user != nil && len(user.Username) > 0 {
|
||||
username = user.Username
|
||||
username = re.ReplaceAllString(user.Username, "-")
|
||||
}
|
||||
|
||||
if FromAddr == "" {
|
||||
@@ -62,7 +67,7 @@ func init() {
|
||||
func Run() {
|
||||
var recipients []string
|
||||
|
||||
// defaults from envars if provided
|
||||
// defaults from env vars if provided
|
||||
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
|
||||
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"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
|
||||
@@ -67,15 +67,16 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
res.MessagesCount = stats.Total
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Search returns the latest messages as JSON
|
||||
@@ -84,7 +85,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// # Search messages
|
||||
//
|
||||
// Returns the latest messages matching a search.
|
||||
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -109,6 +110,11 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
// + name: tz
|
||||
// in: query
|
||||
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
// required: false
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
@@ -121,7 +127,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, start, limit)
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -133,15 +139,16 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = len(messages) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = results
|
||||
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = float64(results)
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
@@ -150,7 +157,7 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Delete all messages matching a search.
|
||||
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -163,6 +170,11 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: tz
|
||||
// in: query
|
||||
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
// required: false
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
@@ -173,7 +185,7 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.DeleteSearch(search); err != nil {
|
||||
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -228,9 +240,10 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(msg); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
@@ -337,10 +350,10 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(m.Header)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadRaw (method: GET) returns the full email source as plain text
|
||||
@@ -428,11 +441,9 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
for _, id := range data.IDs {
|
||||
if err := storage.DeleteOneMessage(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := storage.DeleteMessages(data.IDs); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,16 +540,10 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) {
|
||||
// 200: ArrayResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
tags := storage.GetAllTags()
|
||||
|
||||
data, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessageTags (method: PUT) will set the tags for all provided IDs
|
||||
@@ -628,13 +633,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tos := data.To
|
||||
if len(tos) == 0 {
|
||||
httpError(w, "No valid addresses found")
|
||||
return
|
||||
}
|
||||
|
||||
for _, to := range tos {
|
||||
for _, to := range data.To {
|
||||
address, err := mail.ParseAddress(to)
|
||||
|
||||
if err != nil {
|
||||
@@ -642,12 +641,17 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.To) == 0 {
|
||||
httpError(w, "No valid addresses found")
|
||||
return
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
@@ -655,13 +659,18 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
froms, err := m.Header.AddressList("From")
|
||||
fromAddresses, err := m.Header.AddressList("From")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
from := froms[0].Address
|
||||
if len(fromAddresses) == 0 {
|
||||
httpError(w, "No From header found")
|
||||
return
|
||||
}
|
||||
|
||||
from := fromAddresses[0].Address
|
||||
|
||||
// if sender is used, then change from to the sender
|
||||
if senders, err := m.Header.AddressList("Sender"); err == nil {
|
||||
@@ -696,7 +705,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 {
|
||||
@@ -704,7 +713,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := smtpd.Send(from, tos, msg); err != nil {
|
||||
if err := smtpd.Send(from, data.To, msg); err != nil {
|
||||
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
|
||||
httpError(w, "SMTP error: "+err.Error())
|
||||
return
|
||||
@@ -718,13 +727,10 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
|
||||
//
|
||||
// # HTML check (beta)
|
||||
// # HTML check
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
@@ -764,22 +770,20 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(checks); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// LinkCheck returns a summary of links in the email
|
||||
func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
|
||||
//
|
||||
// # Link check (beta)
|
||||
// # Link check
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
@@ -817,21 +821,19 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// # SpamAssassin check
|
||||
//
|
||||
// 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.
|
||||
// Returns the SpamAssassin summary (if enabled) of the message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
@@ -867,9 +869,10 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(summary)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
@@ -890,6 +893,21 @@ func httpError(w http.ResponseWriter, msg string) {
|
||||
fmt.Fprint(w, msg)
|
||||
}
|
||||
|
||||
// httpJSONError returns a basic error message (400 response) in JSON format
|
||||
func httpJSONError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
e := JSONErrorMessage{
|
||||
Error: msg,
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(e); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
start = 0
|
||||
|
||||
@@ -24,10 +24,8 @@ func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// 200: InfoResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
info := stats.Load()
|
||||
|
||||
bytes, _ := json.Marshal(info)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
275
server/apiv1/send.go
Normal file
275
server/apiv1/send.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// swagger:parameters SendMessage
|
||||
type sendMessageParams struct {
|
||||
// in: body
|
||||
Body *SendRequest
|
||||
}
|
||||
|
||||
// SendRequest to send a message via HTTP
|
||||
// swagger:model SendRequest
|
||||
type SendRequest struct {
|
||||
// "From" recipient
|
||||
// required: true
|
||||
From struct {
|
||||
// Optional name
|
||||
// example: John Doe
|
||||
Name string
|
||||
// Email address
|
||||
// example: john@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// "To" recipients
|
||||
To []struct {
|
||||
// Optional name
|
||||
// example: Jane Doe
|
||||
Name string
|
||||
// Email address
|
||||
// example: jane@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Cc recipients
|
||||
Cc []struct {
|
||||
// Optional name
|
||||
// example: Manager
|
||||
Name string
|
||||
// Email address
|
||||
// example: manager@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Bcc recipients email addresses only
|
||||
// example: ["jack@example.com"]
|
||||
Bcc []string
|
||||
|
||||
// Optional Reply-To recipients
|
||||
ReplyTo []struct {
|
||||
// Optional name
|
||||
// example: Secretary
|
||||
Name string
|
||||
// Email address
|
||||
// example: secretary@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Subject
|
||||
// example: Mailpit message via the HTTP API
|
||||
Subject string
|
||||
|
||||
// Message body (text)
|
||||
// example: This is the text body
|
||||
Text string
|
||||
|
||||
// Message body (HTML)
|
||||
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
|
||||
HTML string
|
||||
|
||||
// Attachments
|
||||
Attachments []struct {
|
||||
// Base64-encoded string of the file content
|
||||
// required: true
|
||||
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
|
||||
Content string
|
||||
// Filename
|
||||
// required: true
|
||||
// example: AttachedFile.txt
|
||||
Filename string
|
||||
}
|
||||
|
||||
// Mailpit tags
|
||||
// example: ["Tag 1","Tag 2"]
|
||||
Tags []string
|
||||
|
||||
// Optional headers in {"key":"value"} format
|
||||
// example: {"X-IP":"1.2.3.4"}
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// SendMessageConfirmation struct
|
||||
type SendMessageConfirmation struct {
|
||||
// Database ID
|
||||
// example: iAfZVVe2UQFNSG5BAjgYwa
|
||||
ID string
|
||||
}
|
||||
|
||||
// JSONErrorMessage struct
|
||||
type JSONErrorMessage struct {
|
||||
// Error message
|
||||
// example: invalid format
|
||||
Error string
|
||||
}
|
||||
|
||||
// SendMessageHandler handles HTTP requests to send a new message
|
||||
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route POST /api/v1/send message SendMessage
|
||||
//
|
||||
// # Send a message
|
||||
//
|
||||
// Send a message via the HTTP API.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: sendMessageResponse
|
||||
// default: jsonErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := SendRequest{}
|
||||
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
httpJSONError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id, err := data.Send(r.RemoteAddr)
|
||||
|
||||
if err != nil {
|
||||
httpJSONError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Send will validate the message structure and attempt to send to Mailpit.
|
||||
// It returns a sending summary or an error.
|
||||
func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
|
||||
}
|
||||
|
||||
ipAddr := &net.IPAddr{IP: net.ParseIP(ip)}
|
||||
|
||||
addresses := []string{}
|
||||
|
||||
msg := enmime.Builder().
|
||||
From(d.From.Name, d.From.Email).
|
||||
Subject(d.Subject).
|
||||
Text([]byte(d.Text))
|
||||
|
||||
if d.HTML != "" {
|
||||
msg = msg.HTML([]byte(d.HTML))
|
||||
}
|
||||
|
||||
if len(d.To) > 0 {
|
||||
for _, a := range d.To {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.To(a.Name, a.Email)
|
||||
addresses = append(addresses, a.Email)
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid To address: %s", a.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Cc) > 0 {
|
||||
for _, a := range d.Cc {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.CC(a.Name, a.Email)
|
||||
addresses = append(addresses, a.Email)
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid Cc address: %s", a.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Bcc) > 0 {
|
||||
for _, e := range d.Bcc {
|
||||
if _, err := mail.ParseAddress(e); err == nil {
|
||||
msg = msg.BCC("", e)
|
||||
addresses = append(addresses, e)
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid Bcc address: %s", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.ReplyTo) > 0 {
|
||||
for _, a := range d.ReplyTo {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.ReplyTo(a.Name, a.Email)
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid Reply-To address: %s", a.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
|
||||
|
||||
if len(d.Tags) > 0 {
|
||||
msg = msg.Header("X-Tags", strings.Join(d.Tags, ", "))
|
||||
restrictedHeaders = append(restrictedHeaders, "X-Tags")
|
||||
}
|
||||
|
||||
if len(d.Headers) > 0 {
|
||||
for k, v := range d.Headers {
|
||||
// check header isn't in "restricted" headers
|
||||
if tools.InArray(k, restrictedHeaders) {
|
||||
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
|
||||
}
|
||||
msg = msg.Header(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Attachments) > 0 {
|
||||
for _, a := range d.Attachments {
|
||||
// workaround: split string because JS readAsDataURL() returns the base64 string
|
||||
// with the mime type prefix eg: data:image/png;base64,<base64String>
|
||||
parts := strings.Split(a.Content, ",")
|
||||
content := parts[len(parts)-1]
|
||||
b, err := base64.StdEncoding.DecodeString(content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(b)
|
||||
msg = msg.AddAttachment(b, mimeType, a.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
part, err := msg.Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error building message: %s", err.Error())
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
|
||||
if err := part.Encode(io.Writer(&buff)); err != nil {
|
||||
return "", fmt.Errorf("error building message: %s", err.Error())
|
||||
}
|
||||
|
||||
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
|
||||
}
|
||||
@@ -10,18 +10,18 @@ import (
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
// Total number of messages in mailbox
|
||||
Total int `json:"total"`
|
||||
Total float64 `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
Unread int `json:"unread"`
|
||||
Unread float64 `json:"unread"`
|
||||
|
||||
// Legacy - now undocumented in API specs but left for backwards compatibility.
|
||||
// Removed from API documentation 2023-07-12
|
||||
// swagger:ignore
|
||||
Count int `json:"count"`
|
||||
Count float64 `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount int `json:"messages_count"`
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
@@ -47,7 +47,7 @@ type deleteMessagesRequestBody struct {
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters SetReadStatus
|
||||
@@ -64,13 +64,13 @@ type setReadStatusRequestBody struct {
|
||||
// required: false
|
||||
// default: false
|
||||
// example: true
|
||||
Read bool `json:"read"`
|
||||
Read bool
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters SetTags
|
||||
@@ -86,13 +86,13 @@ type setTagsRequestBody struct {
|
||||
//
|
||||
// required: true
|
||||
// example: ["Tag 1", "Tag 2"]
|
||||
Tags []string `json:"tags"`
|
||||
Tags []string
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: true
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string `json:"ids"`
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters ReleaseMessage
|
||||
@@ -112,10 +112,9 @@ type releaseMessageParams struct {
|
||||
// swagger:model releaseMessageRequestBody
|
||||
type releaseMessageRequestBody struct {
|
||||
// Array of email addresses to relay the message to
|
||||
//
|
||||
// required: true
|
||||
// example: ["user1@example.com", "user2@example.com"]
|
||||
To []string `json:"to"`
|
||||
To []string
|
||||
}
|
||||
|
||||
// swagger:parameters HTMLCheck
|
||||
@@ -156,7 +155,7 @@ type spamAssassinCheckParams struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Binary data response inherits the attachment's content type
|
||||
// Binary data response inherits the attachment's content type.
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse string
|
||||
|
||||
@@ -170,6 +169,7 @@ type htmlResponse string
|
||||
|
||||
// HTTP error response will return with a >= 400 response code
|
||||
// swagger:response ErrorResponse
|
||||
// example: invalid request
|
||||
type errorResponse string
|
||||
|
||||
// Plain text "ok" response
|
||||
@@ -179,3 +179,21 @@ type okResponse string
|
||||
// Plain JSON array response
|
||||
// swagger:response ArrayResponse
|
||||
type arrayResponse []string
|
||||
|
||||
// Confirmation message for HTTP send API
|
||||
// swagger:response sendMessageResponse
|
||||
type sendMessageResponse struct {
|
||||
// Response for sending messages via the HTTP API
|
||||
//
|
||||
// in: body
|
||||
Body SendMessageConfirmation
|
||||
}
|
||||
|
||||
// JSON error response
|
||||
// swagger:response jsonErrorResponse
|
||||
type jsonErrorResponse struct {
|
||||
// A JSON-encoded error response
|
||||
//
|
||||
// in: body
|
||||
Body JSONErrorMessage
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -74,7 +74,7 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
buf := bytes.NewBuffer(a.Content)
|
||||
|
||||
img, err := imaging.Decode(buf)
|
||||
img, err := imaging.Decode(buf, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
// it's not an image, return default
|
||||
logger.Log().Warnf("[image] %s", err.Error())
|
||||
@@ -114,7 +114,7 @@ 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)
|
||||
|
||||
@@ -20,13 +20,13 @@ 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
|
||||
|
||||
@@ -57,15 +57,16 @@ 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)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = storage.Search(search, 0, 1)
|
||||
messages, _, err = storage.Search(search, "", 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
|
||||
@@ -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{
|
||||
@@ -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")
|
||||
}
|
||||
324
server/pop3/pop3.go
Normal file
324
server/pop3/pop3.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// 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 float64
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
var (
|
||||
user = ""
|
||||
state = 1
|
||||
toDelete = []string{}
|
||||
)
|
||||
|
||||
defer func() {
|
||||
if state == UPDATE {
|
||||
for _, id := range toDelete {
|
||||
_ = storage.DeleteMessages([]string{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 := float64(0)
|
||||
for _, m := range messages {
|
||||
totalSize = totalSize + m.Size
|
||||
}
|
||||
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
|
||||
|
||||
} else if cmd == "LIST" && state == TRANSACTION {
|
||||
totalSize := float64(0)
|
||||
for _, m := range messages {
|
||||
totalSize = totalSize + m.Size
|
||||
}
|
||||
sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(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 := float64(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))
|
||||
|
||||
// When all lines of the response have been sent, a
|
||||
// final line is sent, consisting of a termination octet (decimal code
|
||||
// 046, ".") and a CRLF pair. If any line of the multi-line response
|
||||
// begins with the termination octet, the line is "byte-stuffed" by
|
||||
// pre-pending the termination octet to that line of the response.
|
||||
// @see: https://www.ietf.org/rfc/rfc1939.txt
|
||||
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
|
||||
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,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -47,6 +49,8 @@ func Listen() {
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
|
||||
go pop3.Run()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
// kubernetes probes
|
||||
@@ -92,14 +96,27 @@ 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 on %s (TLS)", config.HTTPListen)
|
||||
logger.Log().Infof("[http] accessible via https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
|
||||
if err := server.ListenAndServeTLS(config.UITLSCert, config.UITLSKey); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
|
||||
logger.Log().Infof("[http] accessible via http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
|
||||
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +127,17 @@ 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.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/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
|
||||
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/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
if !config.DisableHTMLCheck {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||
}
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/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")
|
||||
@@ -183,20 +199,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.UICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
fn(w, r)
|
||||
return
|
||||
@@ -322,8 +324,6 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buff.Bytes()
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,8 +21,8 @@ import (
|
||||
|
||||
var (
|
||||
putDataStruct struct {
|
||||
Read bool `json:"read"`
|
||||
IDs []string `json:"ids"`
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
)
|
||||
|
||||
@@ -47,8 +48,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 +55,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 +69,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
|
||||
@@ -207,14 +202,118 @@ func TestAPIv1Search(t *testing.T) {
|
||||
assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99)
|
||||
}
|
||||
|
||||
func TestAPIv1Send(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
r := apiRoutes()
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
jsonData := `{
|
||||
"From": {
|
||||
"Email": "john@example.com",
|
||||
"Name": "John Doe"
|
||||
},
|
||||
"To": [
|
||||
{
|
||||
"Email": "jane@example.com",
|
||||
"Name": "Jane Doe"
|
||||
}
|
||||
],
|
||||
"Cc": [
|
||||
{
|
||||
"Email": "manager1@example.com",
|
||||
"Name": "Manager 1"
|
||||
},
|
||||
{
|
||||
"Email": "manager2@example.com",
|
||||
"Name": "Manager 2"
|
||||
}
|
||||
],
|
||||
"Bcc": ["jack@example.com"],
|
||||
"Headers": {
|
||||
"X-IP": "1.2.3.4"
|
||||
},
|
||||
"Subject": "Mailpit message via the HTTP API",
|
||||
"Text": "This is the text body",
|
||||
"HTML": "<p style=\"font-family: arial\">Mailpit is <b>awesome</b>!</p>",
|
||||
"Attachments": [
|
||||
{
|
||||
"Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==",
|
||||
"Filename": "Attached File.txt"
|
||||
}
|
||||
],
|
||||
"ReplyTo": [
|
||||
{
|
||||
"Email": "secretary@example.com",
|
||||
"Name": "Secretary"
|
||||
}
|
||||
],
|
||||
"Tags": [
|
||||
"Tag 1",
|
||||
"Tag 2"
|
||||
]
|
||||
}`
|
||||
|
||||
t.Log("Sending message via HTTP API")
|
||||
b, err := clientPost(ts.URL+"/api/v1/send", jsonData)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, received %s", err.Error())
|
||||
}
|
||||
|
||||
resp := apiv1.SendMessageConfirmation{}
|
||||
|
||||
if err := json.Unmarshal(b, &resp); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("Fetching response for message %s", resp.ID)
|
||||
msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Testing response for message %s", resp.ID)
|
||||
assertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, "wrong subject")
|
||||
assertEqual(t, `This is the text body`, msg.Text, "wrong text")
|
||||
assertEqual(t, `<p style="font-family: arial">Mailpit is <b>awesome</b>!</p>`, msg.HTML, "wrong HTML")
|
||||
assertEqual(t, `"John Doe" <john@example.com>`, msg.From.String(), "wrong HTML")
|
||||
assertEqual(t, 1, len(msg.To), "wrong To count")
|
||||
assertEqual(t, `"Jane Doe" <jane@example.com>`, msg.To[0].String(), "wrong To address")
|
||||
assertEqual(t, 2, len(msg.Cc), "wrong Cc count")
|
||||
assertEqual(t, `"Manager 1" <manager1@example.com>`, msg.Cc[0].String(), "wrong Cc address")
|
||||
assertEqual(t, `"Manager 2" <manager2@example.com>`, msg.Cc[1].String(), "wrong Cc address")
|
||||
assertEqual(t, 1, len(msg.Bcc), "wrong Bcc count")
|
||||
assertEqual(t, `<jack@example.com>`, msg.Bcc[0].String(), "wrong Bcc address")
|
||||
assertEqual(t, 1, len(msg.ReplyTo), "wrong Reply-To count")
|
||||
assertEqual(t, `"Secretary" <secretary@example.com>`, msg.ReplyTo[0].String(), "wrong Reply-To address")
|
||||
assertEqual(t, 2, len(msg.Tags), "wrong Tags count")
|
||||
assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags")
|
||||
assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count")
|
||||
assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name")
|
||||
|
||||
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
}
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.DataFile = ""
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
|
||||
if err := storage.InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := storage.DeleteAllMessages(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
@@ -231,8 +330,8 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, unread, m.Unread, "wrong unread count")
|
||||
assertEqual(t, total, m.Total, "wrong total count")
|
||||
assertEqual(t, float64(unread), m.Unread, "wrong unread count")
|
||||
assertEqual(t, float64(total), m.Total, "wrong total count")
|
||||
}
|
||||
|
||||
func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
@@ -252,7 +351,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, m.MessagesCount, "wrong search results count")
|
||||
assertEqual(t, float64(count), m.MessagesCount, "wrong search results count")
|
||||
}
|
||||
|
||||
func insertEmailData(t *testing.T) {
|
||||
@@ -289,7 +388,21 @@ func insertEmailData(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchMessage(url string) (storage.Message, error) {
|
||||
m := storage.Message{}
|
||||
|
||||
data, err := clientGet(url)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
|
||||
@@ -373,6 +486,31 @@ func clientPut(url, body string) ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPost(url, body string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("POST", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
|
||||
41
server/smtpd/relay.go
Normal file
41
server/smtpd/relay.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
if len(to) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if config.SMTPRelayAll {
|
||||
if err := Send(from, to, *data); err != nil {
|
||||
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
} else if config.SMTPRelayMatchingRegexp != nil {
|
||||
filtered := []string{}
|
||||
for _, t := range to {
|
||||
if config.SMTPRelayMatchingRegexp.MatchString(t) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := Send(from, filtered, *data); err != nil {
|
||||
logger.Log().Errorf("[smtp] error relaying message: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,46 +4,14 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func allowedRecipients(to []string) []string {
|
||||
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
|
||||
return to
|
||||
}
|
||||
|
||||
var ar []string
|
||||
|
||||
for _, recipient := range to {
|
||||
address, err := mail.ParseAddress(recipient)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid email address: %s", recipient)
|
||||
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)
|
||||
} else {
|
||||
ar = append(ar, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
return ar
|
||||
}
|
||||
|
||||
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Send(from string, to []string, msg []byte) error {
|
||||
recipients := allowedRecipients(to)
|
||||
|
||||
if len(recipients) == 0 {
|
||||
return errors.New("no valid recipients")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
@@ -54,7 +22,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
|
||||
|
||||
@@ -75,7 +43,7 @@ func Send(from string, to []string, msg []byte) error {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
for _, addr := range recipients {
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,13 @@ var (
|
||||
DisableReverseDNS bool
|
||||
)
|
||||
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
return Store(origin, from, to, data)
|
||||
}
|
||||
|
||||
// Store will attempt to save a message to the database
|
||||
func Store(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
if !config.SMTPStrictRFCHeaders {
|
||||
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
@@ -34,7 +40,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
stats.LogSMTPRejected()
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// check / set the Return-Path based on SMTP from
|
||||
@@ -63,25 +69,19 @@ 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
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// if enabled, this will route the email 1:1 through to the preconfigured smtp server
|
||||
if config.SMTPRelayAllIncoming {
|
||||
if err := Send(from, to, data); err != nil {
|
||||
logger.Log().Warnf("[smtp] error relaying message: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[smtp] relayed message from %s via %s:%d", from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
|
||||
autoRelayMessage(from, to, &data)
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
@@ -123,10 +123,10 @@ 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)
|
||||
id, err := storage.Store(&data)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
stats.LogSMTPAccepted(len(data))
|
||||
@@ -136,7 +136,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
subject := msg.Header.Get("Subject")
|
||||
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
|
||||
|
||||
return nil
|
||||
return id, err
|
||||
}
|
||||
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
|
||||
@@ -177,27 +177,43 @@ func handlerRcpt(remoteAddr net.Addr, from string, to string) bool {
|
||||
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", 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 {
|
||||
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
|
||||
srv := &smtpd.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
Appname: "Mailpit",
|
||||
Hostname: "",
|
||||
@@ -221,7 +237,8 @@ func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHa
|
||||
}
|
||||
|
||||
if config.SMTPTLSCert != "" {
|
||||
srv.TLSRequired = config.SMTPTLSRequired
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export default {
|
||||
|
||||
beforeMount() {
|
||||
document.title = document.title + ' - ' + location.hostname
|
||||
mailbox.showTagColors = !localStorage.getItem('hideTagColors') == '1'
|
||||
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
|
||||
@@ -320,16 +320,18 @@ body.blur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
#message-view {
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
input {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
input {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: text; // html5-tags
|
||||
div {
|
||||
cursor: text; // html5-tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,14 +357,6 @@ body.blur {
|
||||
}
|
||||
}
|
||||
|
||||
#ReleaseModal {
|
||||
.form-control.dropdown {
|
||||
div {
|
||||
@extend .form-control;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PrismJS 1.29.0 - modified!
|
||||
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
|
||||
code[class*="language-"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import AjaxLoader from './AjaxLoader.vue'
|
||||
import Settings from '../components/Settings.vue'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
@@ -7,7 +8,8 @@ export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
components: {
|
||||
AjaxLoader
|
||||
AjaxLoader,
|
||||
Settings,
|
||||
},
|
||||
|
||||
props: {
|
||||
@@ -20,20 +22,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: 'auto',
|
||||
icon: 'circle-half',
|
||||
icons: {
|
||||
'auto': 'circle-half',
|
||||
'light': 'sun-fill',
|
||||
'dark': 'moon-stars-fill'
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme(this.getPreferredTheme())
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadInfo: function () {
|
||||
let self = this
|
||||
@@ -43,42 +34,6 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
getStoredTheme: function () {
|
||||
let theme = localStorage.getItem('theme')
|
||||
if (!theme) {
|
||||
theme = 'auto'
|
||||
}
|
||||
|
||||
return theme
|
||||
},
|
||||
|
||||
setStoredTheme: function (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
this.setTheme(theme)
|
||||
},
|
||||
|
||||
getPreferredTheme: function () {
|
||||
const storedTheme = this.getStoredTheme()
|
||||
if (storedTheme) {
|
||||
return storedTheme
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
},
|
||||
|
||||
setTheme: function (theme) {
|
||||
this.icon = this.icons[theme]
|
||||
this.theme = theme
|
||||
if (
|
||||
theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function () {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
@@ -101,42 +56,17 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<div
|
||||
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
|
||||
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
|
||||
<i :class="'bi bi-' + icon + ' my-1'"></i>
|
||||
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
|
||||
<i class="bi bi-sun-fill me-2 opacity-50"></i>
|
||||
Light
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
|
||||
<i class="bi bi-moon-stars-fill me-2 opacity-50"></i>
|
||||
Dark
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
|
||||
<i class="bi bi-circle-half me-2 opacity-50"></i>
|
||||
Auto
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
|
||||
@@ -160,18 +90,20 @@ 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-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">
|
||||
@@ -196,7 +128,8 @@ export default {
|
||||
<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>
|
||||
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,8 +137,9 @@ export default {
|
||||
<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>
|
||||
<h5 class="card-title">
|
||||
{{ getFileSize(mailbox.appInfo.RuntimeStats.Memory) }}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +149,8 @@ export default {
|
||||
<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">
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -224,7 +159,7 @@ export default {
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Mailpit uptime
|
||||
Mailpit up since
|
||||
</td>
|
||||
<td>
|
||||
{{ secondsToRelative(mailbox.appInfo.RuntimeStats.Uptime) }}
|
||||
@@ -245,9 +180,7 @@ export default {
|
||||
<td>
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-secondary">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
({{ getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) }})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -284,8 +217,8 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1"
|
||||
aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -297,7 +230,8 @@ export default {
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click
|
||||
<code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the
|
||||
notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -308,7 +242,9 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Settings />
|
||||
</template>
|
||||
|
||||
<AjaxLoader :loading="loading" />
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import moment from 'moment'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
@@ -19,32 +19,26 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
moment.updateLocale('en', {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
past: "%s ago",
|
||||
s: 'seconds',
|
||||
ss: '%d secs',
|
||||
m: "a minute",
|
||||
mm: "%d mins",
|
||||
h: "an hour",
|
||||
hh: "%d hours",
|
||||
d: "a day",
|
||||
dd: "%d days",
|
||||
w: "a week",
|
||||
ww: "%d weeks",
|
||||
M: "a month",
|
||||
MM: "%d months",
|
||||
y: "a year",
|
||||
yy: "%d years"
|
||||
}
|
||||
})
|
||||
let relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
this.refreshUI()
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshUI: function () {
|
||||
let self = this
|
||||
window.setTimeout(
|
||||
() => {
|
||||
self.$forceUpdate()
|
||||
self.refreshUI()
|
||||
},
|
||||
30000
|
||||
)
|
||||
},
|
||||
|
||||
getRelativeCreated: function (message) {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString()
|
||||
return dayjs(d).fromNow()
|
||||
},
|
||||
|
||||
getPrimaryEmailTo: function (message) {
|
||||
@@ -112,7 +106,8 @@ export default {
|
||||
<template>
|
||||
<template v-if="mailbox.messages && mailbox.messages.length">
|
||||
<div class="list-group my-2">
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" :id="message.ID"
|
||||
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
|
||||
:id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
|
||||
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
|
||||
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)">
|
||||
@@ -123,15 +118,15 @@ export default {
|
||||
</div>
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</span>
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-lg-block privacy">
|
||||
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</b>
|
||||
message.From.Name ?
|
||||
message.From.Name : message.From.Address
|
||||
}}</b>
|
||||
</div>
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
if (!mailbox.selected.length) {
|
||||
return false
|
||||
}
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
|
||||
self.put(self.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
@@ -59,7 +59,7 @@ export default {
|
||||
if (!ids.length) {
|
||||
return false
|
||||
}
|
||||
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
|
||||
self.delete(self.resolve(`/api/v1/messages`), { 'IDs': ids }, function (response) {
|
||||
window.scrollInPlace = true
|
||||
self.loadMessages()
|
||||
})
|
||||
|
||||
@@ -64,9 +64,11 @@ export default {
|
||||
}
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
mailbox.tags.push(response.Data.Tags[i])
|
||||
mailbox.tags.sort()
|
||||
mailbox.tags.sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
server/ui-src/components/Settings.vue
Normal file
119
server/ui-src/components/Settings.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import CommonMixins from '../mixins/CommonMixins'
|
||||
import Tags from 'bootstrap5-tags'
|
||||
import timezones from 'timezones-list'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
theme: function (v) {
|
||||
if (v == 'auto') {
|
||||
localStorage.removeItem('theme')
|
||||
} else {
|
||||
localStorage.setItem('theme', v)
|
||||
}
|
||||
this.setTheme()
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setTheme()
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
setTheme: function () {
|
||||
if (
|
||||
this.theme === 'auto' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal fade" id="SettingsModal" tabindex="-1" aria-labelledby="SettingsModalLabel" aria-hidden="true"
|
||||
data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit UI settings</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="theme" class="form-label">Mailpit theme</label>
|
||||
<select class="form-select" v-model="theme" id="theme">
|
||||
<option value="auto">Auto (detect from browser)</option>
|
||||
<option value="light">Light theme</option>
|
||||
<option value="dark">Dark theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="timezone" class="form-label">Timezone (for date searches)</label>
|
||||
<select class="form-select tz" v-model="mailbox.timeZone" id="timezone" data-allow-same="true">
|
||||
<option disabled hidden value="">Select a timezone...</option>
|
||||
<option v-for="t in timezones" :value="t.tzCode">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="tagColors"
|
||||
v-model="mailbox.showTagColors">
|
||||
<label class="form-check-label" for="tagColors">
|
||||
Use auto-generated tag colors
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="htmlCheck"
|
||||
v-model="mailbox.showHTMLCheck">
|
||||
<label class="form-check-label" for="htmlCheck">
|
||||
Show HTML check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="linkCheck"
|
||||
v-model="mailbox.showLinkCheck">
|
||||
<label class="form-check-label" for="linkCheck">
|
||||
Show link check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="spamCheck"
|
||||
v-model="mailbox.showSpamCheck">
|
||||
<label class="form-check-label" for="spamCheck">
|
||||
Show spam check message tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
<script>
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import ICAL from "ical.js"
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -8,16 +9,72 @@ export default {
|
||||
attachments: Object
|
||||
},
|
||||
|
||||
mixins: [commonMixins]
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
ical: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
openAttachment: function (part, e) {
|
||||
let filename = part.FileName
|
||||
let contentType = part.ContentType
|
||||
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)
|
||||
if (filename.match(/\.ics$/i) || contentType == 'text/calendar') {
|
||||
e.preventDefault()
|
||||
|
||||
this.get(href, null, (response) => {
|
||||
let comp = new ICAL.Component(ICAL.parse(response.data))
|
||||
let vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) {
|
||||
alert('Error parsing ICS file')
|
||||
return
|
||||
}
|
||||
let event = new ICAL.Event(vevent)
|
||||
|
||||
let summary = {}
|
||||
summary.link = href
|
||||
summary.status = vevent.getFirstPropertyValue('status')
|
||||
summary.url = vevent.getFirstPropertyValue('url')
|
||||
summary.summary = event.summary
|
||||
summary.description = event.description
|
||||
summary.location = event.location
|
||||
summary.start = dayjs(event.startDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.end = dayjs(event.endDate).format('ddd, D MMM YYYY, h:mm a')
|
||||
summary.isRecurring = event.isRecurring()
|
||||
summary.organizer = event.organizer ? event.organizer.replace(/^mailto:/, '') : false
|
||||
summary.attendees = []
|
||||
event.attendees.forEach((a) => {
|
||||
if (a.jCal[1].cn) {
|
||||
summary.attendees.push(a.jCal[1].cn)
|
||||
}
|
||||
})
|
||||
|
||||
comp.getAllSubcomponents("vtimezone").forEach((vtimezone) => {
|
||||
summary.timezone = vtimezone.getFirstPropertyValue("tzid")
|
||||
})
|
||||
|
||||
this.ical = summary
|
||||
|
||||
// display modal
|
||||
this.modal('ICSView').show()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 border-top pt-4">
|
||||
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
|
||||
<img v-if="isImage(part)" :src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
|
||||
class="card-img-top" alt="">
|
||||
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px"
|
||||
@click="openAttachment(part, $event)">
|
||||
<img v-if="isImage(part)"
|
||||
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')" class="card-img-top"
|
||||
alt="">
|
||||
<img v-else
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
|
||||
class="card-img-top" alt="">
|
||||
@@ -30,12 +87,79 @@ export default {
|
||||
<small>{{ getFileSize(part.Size) }}</small>
|
||||
</p>
|
||||
<p class="card-text mb-0 small">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer small border-0 text-center text-truncate">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' + part.ContentType }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ICSView" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fs-5">
|
||||
<i class="bi bi-calendar-event me-2"></i>
|
||||
iCalendar summary
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="ical">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr v-if="ical.summary">
|
||||
<th>Summary</th>
|
||||
<td>{{ ical.summary }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.description">
|
||||
<th>Description</th>
|
||||
<td>{{ ical.description }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<td>
|
||||
{{ ical.start }} — {{ ical.end }}
|
||||
<span v-if="ical.isRecurring">(recurring)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ical.status">
|
||||
<th>Status</th>
|
||||
<td> {{ ical.status }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.location">
|
||||
<th>Location</th>
|
||||
<td>{{ ical.location }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.url">
|
||||
<th>URL</th>
|
||||
<td><a :href="ical.url" target="_blank">{{ ical.url }}</a></td>
|
||||
</tr>
|
||||
<tr v-if="ical.organizer">
|
||||
<th>Organizer</th>
|
||||
<td>{{ ical.organizer }}</td>
|
||||
</tr>
|
||||
<tr v-if="ical.attendees.length">
|
||||
<th>Attendees</th>
|
||||
<td>
|
||||
<span v-for="(a, i) in ical.attendees">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ a }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<a class="btn btn-primary" target="_blank" :href="ical.link">
|
||||
Download attachment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
enabled: true,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
@@ -37,7 +36,6 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.enabled = !localStorage.getItem('htmlCheckDisabled')
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
},
|
||||
@@ -46,7 +44,7 @@ export default {
|
||||
summary: function () {
|
||||
let self = this
|
||||
|
||||
if (!this.enabled || !this.check) {
|
||||
if (!this.check) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -199,23 +197,19 @@ export default {
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
},
|
||||
enabled(v) {
|
||||
if (!v) {
|
||||
localStorage.setItem('htmlCheckDisabled', true)
|
||||
this.$emit('setHtmlScore', false)
|
||||
} else {
|
||||
localStorage.removeItem('htmlCheckDisabled')
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
// enabled(v) {
|
||||
// if (!v) {
|
||||
// localStorage.setItem('htmlCheckDisabled', true)
|
||||
// this.$emit('setHtmlScore', false)
|
||||
// } else {
|
||||
// localStorage.removeItem('htmlCheckDisabled')
|
||||
// this.doCheck()
|
||||
// }
|
||||
// }
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck: function () {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.check = false
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
@@ -314,20 +308,6 @@ export default {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!enabled">
|
||||
<h2 class="h4 text-secondary">HTML check is currently disabled</h2>
|
||||
<p class="text-secondary">
|
||||
This feature is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck">
|
||||
<label class="form-check-label" for="inlineEnableHTMLCheck">
|
||||
Enable HTML check
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="summary">
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
@@ -368,10 +348,6 @@ export default {
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
@@ -500,10 +476,6 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML check 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="HTMLCheckAboutAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
@@ -635,36 +607,4 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" 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="HTMLCheckOptionsLabel">HTML check options</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML check is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled"
|
||||
id="HTMLCheckSwitch">
|
||||
<label class="form-check-label" for="HTMLCheckSwitch">
|
||||
<template v-if="enabled">HTML check is enabled in the web UI</template>
|
||||
<template v-else>HTML check is disabled in the web UI</template>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-4 small text-center text-secondary">
|
||||
HTML check can be globally disabled with <code>--disable-html-check</code><br>
|
||||
Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -247,11 +247,6 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Link check is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
|
||||
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
|
||||
@@ -293,10 +288,6 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Link check 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="LinkCheckAboutAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<script>
|
||||
import Attachments from './Attachments.vue'
|
||||
import Headers from './Headers.vue'
|
||||
@@ -32,6 +31,7 @@ export default {
|
||||
srcURI: false,
|
||||
iframes: [], // for resizing
|
||||
canSaveTags: false, // prevent auto-saving tags on render
|
||||
availableTags: [],
|
||||
messageTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
@@ -69,6 +69,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasAnyChecksEnabled: function () {
|
||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
||||
|| mailbox.showLinkCheck
|
||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
let self = this
|
||||
self.canSaveTags = false
|
||||
@@ -90,7 +98,7 @@ export default {
|
||||
|
||||
// manually refresh tags
|
||||
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
|
||||
mailbox.tags = response.data
|
||||
self.availableTags = response.data
|
||||
self.$nextTick(function () {
|
||||
Tags.init('select[multiple]')
|
||||
// delay tag change detection to allow Tags to load
|
||||
@@ -190,8 +198,8 @@ export default {
|
||||
let self = this
|
||||
|
||||
var data = {
|
||||
ids: [this.message.ID],
|
||||
tags: this.messageTags
|
||||
IDs: [this.message.ID],
|
||||
Tags: this.messageTags
|
||||
}
|
||||
|
||||
self.put(self.resolve('/api/v1/tags'), data, function (response) {
|
||||
@@ -239,7 +247,8 @@ export default {
|
||||
<th class="small">From</th>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name" class="text-spaces">{{ 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 }}
|
||||
@@ -262,7 +271,7 @@ export default {
|
||||
<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>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
@@ -277,7 +286,7 @@ 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>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
@@ -301,7 +310,7 @@ export default {
|
||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||
<th class="text-nowrap">Reply-To</th>
|
||||
<td class="privacy text-body-secondary text-break">
|
||||
<span v-for="( t, i ) in message.ReplyTo ">
|
||||
<span v-for="(t, i) in message.ReplyTo">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||
@@ -337,11 +346,11 @@ 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 -->
|
||||
<option v-for=" t in mailbox.tags " :value="t">{{ t }}</option>
|
||||
<option v-for="t in availableTags" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<div class="invalid-feedback">Invalid tag name</div>
|
||||
</td>
|
||||
@@ -422,15 +431,16 @@ export default {
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div class="dropdown d-xl-none">
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<div class="dropdown d-xl-none" v-show="hasAnyChecksEnabled">
|
||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
Checks
|
||||
</button>
|
||||
<ul class="dropdown-menu checks">
|
||||
<li>
|
||||
<li v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
<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 != ''">
|
||||
aria-selected="false">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
@@ -438,7 +448,7 @@ export default {
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<li v-if="mailbox.showLinkCheck">
|
||||
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false">
|
||||
@@ -451,7 +461,7 @@ export default {
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.SpamAssassin">
|
||||
<li v-if="mailbox.showSpamCheck && 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">
|
||||
@@ -465,8 +475,8 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" 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 != ''">
|
||||
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab"
|
||||
aria-controls="nav-html" aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
@@ -474,7 +484,7 @@ export default {
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||
aria-selected="false">
|
||||
aria-selected="false" v-if="mailbox.showLinkCheck">
|
||||
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">
|
||||
@@ -482,8 +492,9 @@ export default {
|
||||
</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">
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab"
|
||||
aria-controls="nav-html" aria-selected="false"
|
||||
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
@@ -491,7 +502,7 @@ export default {
|
||||
</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>
|
||||
@@ -505,22 +516,25 @@ export default {
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizeHTML(message.HTML)"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
|
||||
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
|
||||
style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
|
||||
tabindex="0" :class="message.HTML == '' ? 'show' : ''">
|
||||
<div class="text-view" v-html="textToHTML(message.Text)"></div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
:attachments="allAttachments(message)">
|
||||
</Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
|
||||
<Headers v-if="loadHeaders" :message="message"></Headers>
|
||||
@@ -531,16 +545,16 @@ export default {
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
|
||||
<HTMLCheck v-if="mailbox.showHTMLCheck && 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"
|
||||
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
<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">
|
||||
tabindex="0" v-if="mailbox.showLinkCheck">
|
||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<script>
|
||||
import AjaxLoader from '../AjaxLoader.vue'
|
||||
import Tags from "bootstrap5-tags"
|
||||
@@ -60,7 +59,7 @@ export default {
|
||||
}
|
||||
|
||||
let data = {
|
||||
to: self.addresses
|
||||
To: self.addresses
|
||||
}
|
||||
|
||||
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
|
||||
@@ -103,8 +102,9 @@ export default {
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
|
||||
data-clear-end="true" data-allow-clear="true" data-placeholder="Enter email addresses..."
|
||||
data-add-on-blur="true" data-badge-style="primary"
|
||||
data-clear-end="true" data-allow-clear="true"
|
||||
data-placeholder="Enter email addresses..." data-add-on-blur="true"
|
||||
data-badge-style="primary"
|
||||
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
|
||||
data-separator="|,|">
|
||||
<option value="">Enter email addresses...</option>
|
||||
@@ -126,17 +126,18 @@ export default {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.RecipientAllowlist != ''">
|
||||
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>
|
||||
<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">
|
||||
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.
|
||||
<br class="d-none d-md-inline">
|
||||
SMTP delivery failures will bounce back to
|
||||
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">{{ mailbox.uiConfig.MessageRelay.ReturnPath
|
||||
}}</b>
|
||||
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
|
||||
</b>
|
||||
<b v-else>{{ message.ReturnPath }}</b>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,11 @@ export default {
|
||||
value: p,
|
||||
color: c
|
||||
},
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
scoreColor: function () {
|
||||
return this.graphSections[0].color
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -202,10 +206,6 @@ export default {
|
||||
<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">
|
||||
@@ -214,13 +214,14 @@ export default {
|
||||
What is Spam Analysis?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<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.
|
||||
attachments) to a running SpamAssassin server and then displays the results
|
||||
returned by SpamAssassin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,16 +233,17 @@ export default {
|
||||
How does the point system work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<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.
|
||||
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>
|
||||
@@ -253,7 +255,8 @@ export default {
|
||||
But I don't agree with the results...
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<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
|
||||
@@ -261,8 +264,9 @@ export default {
|
||||
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.
|
||||
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>
|
||||
@@ -274,14 +278,15 @@ export default {
|
||||
Where can I find more information about the triggered rules?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||
<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.
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import moment from 'moment'
|
||||
import dayjs from 'dayjs'
|
||||
import ColorHash from 'color-hash'
|
||||
import { Modal, Offcanvas } from 'bootstrap'
|
||||
|
||||
@@ -45,11 +45,11 @@ export default {
|
||||
},
|
||||
|
||||
messageDate: function (d) {
|
||||
return moment(d).format('ddd, D MMM YYYY, h:mm a')
|
||||
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
|
||||
},
|
||||
|
||||
secondsToRelative: function (d) {
|
||||
return moment().subtract(d, 'seconds').fromNow()
|
||||
return dayjs().subtract(d, 'seconds').fromNow()
|
||||
},
|
||||
|
||||
tagEncodeURI: function (tag) {
|
||||
@@ -243,6 +243,9 @@ export default {
|
||||
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
|
||||
return 'bi-file-zip-fill'
|
||||
}
|
||||
if (['ics'].includes(ext)) {
|
||||
return 'bi-calendar-event'
|
||||
}
|
||||
if (a.ContentType.match(/^audio\//)) {
|
||||
return 'bi-file-music-fill'
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 129 KiB |
@@ -9,7 +9,6 @@ export const mailbox = reactive({
|
||||
count: 0, // total in mailbox or search
|
||||
messages: [], // current messages
|
||||
tags: [], // all tags
|
||||
showTagColors: true, // show/hide tag colors
|
||||
selected: [], // currently selected
|
||||
connected: false, // websocket connection
|
||||
searching: false, // current search, false for none
|
||||
@@ -19,6 +18,13 @@ export const mailbox = reactive({
|
||||
appInfo: {}, // application information
|
||||
uiConfig: {}, // configuration for UI
|
||||
lastMessage: false, // return scrolling
|
||||
|
||||
// settings
|
||||
showTagColors: !localStorage.getItem('hideTagColors') == '1',
|
||||
showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1',
|
||||
showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1',
|
||||
showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1',
|
||||
timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -38,3 +44,47 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailbox.showHTMLCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideHTMLCheck')
|
||||
} else {
|
||||
localStorage.setItem('hideHTMLCheck', '1')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailbox.showLinkCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideLinkCheck')
|
||||
} else {
|
||||
localStorage.setItem('hideLinkCheck', '1')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailbox.showSpamCheck,
|
||||
(v) => {
|
||||
if (v) {
|
||||
localStorage.removeItem('hideSpamCheck')
|
||||
} else {
|
||||
localStorage.setItem('hideSpamCheck', '1')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mailbox.timeZone,
|
||||
(v) => {
|
||||
if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
||||
localStorage.removeItem('timeZone')
|
||||
} else {
|
||||
localStorage.setItem('timeZone', v)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,6 +59,9 @@ export default {
|
||||
}
|
||||
|
||||
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
|
||||
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
|
||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
}
|
||||
this.loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/html-check": {
|
||||
"get": {
|
||||
"description": "Returns the summary of the message HTML checker.\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.",
|
||||
"description": "Returns the summary of the message HTML checker.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -135,7 +135,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "HTML check (beta)",
|
||||
"summary": "HTML check",
|
||||
"operationId": "HTMLCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -161,7 +161,7 @@
|
||||
},
|
||||
"/api/v1/message/{ID}/link-check": {
|
||||
"get": {
|
||||
"description": "Returns the summary of the message Link checker.\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.",
|
||||
"description": "Returns the summary of the message Link checker.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -172,7 +172,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "Link check (beta)",
|
||||
"summary": "Link check",
|
||||
"operationId": "LinkCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -368,7 +368,7 @@
|
||||
},
|
||||
"/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.",
|
||||
"description": "Returns the SpamAssassin summary (if enabled) of the message.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -379,7 +379,7 @@
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "SpamAssassin check (beta)",
|
||||
"summary": "SpamAssassin check",
|
||||
"operationId": "SpamAssassinCheck",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -516,7 +516,7 @@
|
||||
},
|
||||
"/api/v1/search": {
|
||||
"get": {
|
||||
"description": "Returns the latest messages matching a search.",
|
||||
"description": "Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -550,6 +550,12 @@
|
||||
"description": "Limit results",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
|
||||
"name": "tz",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -562,7 +568,7 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Delete all messages matching a search.",
|
||||
"description": "Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -582,6 +588,12 @@
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").",
|
||||
"name": "tz",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -594,6 +606,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/send": {
|
||||
"post": {
|
||||
"description": "Send a message via the HTTP API.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"message"
|
||||
],
|
||||
"summary": "Send a message",
|
||||
"operationId": "SendMessage",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/SendRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/sendMessageResponse"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/jsonErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/tags": {
|
||||
"get": {
|
||||
"description": "Returns a JSON array of all unique message tags.",
|
||||
@@ -773,8 +822,8 @@
|
||||
},
|
||||
"DatabaseSize": {
|
||||
"description": "Database size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"LatestVersion": {
|
||||
"description": "Latest Mailpit version",
|
||||
@@ -782,8 +831,8 @@
|
||||
},
|
||||
"Messages": {
|
||||
"description": "Total number of messages in the database",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"RuntimeStats": {
|
||||
"description": "Runtime statistics",
|
||||
@@ -796,33 +845,33 @@
|
||||
},
|
||||
"MessagesDeleted": {
|
||||
"description": "Database runtime messages deleted",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"SMTPAccepted": {
|
||||
"description": "Accepted runtime SMTP messages",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"SMTPAcceptedSize": {
|
||||
"description": "Total runtime accepted messages size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"SMTPIgnored": {
|
||||
"description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"SMTPRejected": {
|
||||
"description": "Rejected runtime SMTP messages",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"Uptime": {
|
||||
"description": "Mailpit server uptime in seconds",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -836,8 +885,8 @@
|
||||
},
|
||||
"Unread": {
|
||||
"description": "Total number of messages in the database",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"Version": {
|
||||
"description": "Current Mailpit version",
|
||||
@@ -868,8 +917,8 @@
|
||||
},
|
||||
"Size": {
|
||||
"description": "Size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/storage"
|
||||
@@ -878,13 +927,12 @@
|
||||
"description": "Delete request",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"IDs": {
|
||||
"description": "Array of message database IDs",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "IDs",
|
||||
"example": [
|
||||
"5dec4247-812e-4b77-9101-e25ad406e9ea",
|
||||
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
|
||||
@@ -1071,6 +1119,18 @@
|
||||
"x-go-name": "Warning",
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/htmlcheck"
|
||||
},
|
||||
"JSONErrorMessage": {
|
||||
"description": "JSONErrorMessage struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Error": {
|
||||
"description": "Error message",
|
||||
"type": "string",
|
||||
"example": "invalid format"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"Link": {
|
||||
"description": "Link struct",
|
||||
"type": "object",
|
||||
@@ -1176,8 +1236,8 @@
|
||||
},
|
||||
"Size": {
|
||||
"description": "Message size in bytes",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"Subject": {
|
||||
"description": "Message subject",
|
||||
@@ -1259,10 +1319,17 @@
|
||||
"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",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"Snippet": {
|
||||
"description": "Message snippet includes up to 250 characters",
|
||||
@@ -1303,8 +1370,8 @@
|
||||
},
|
||||
"messages_count": {
|
||||
"description": "Total number of messages matching current query",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"x-go-name": "MessagesCount"
|
||||
},
|
||||
"start": {
|
||||
@@ -1323,14 +1390,14 @@
|
||||
},
|
||||
"total": {
|
||||
"description": "Total number of messages in mailbox",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"x-go-name": "Total"
|
||||
},
|
||||
"unread": {
|
||||
"description": "Total number of unread messages in mailbox",
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"x-go-name": "Unread"
|
||||
}
|
||||
},
|
||||
@@ -1356,6 +1423,182 @@
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
|
||||
},
|
||||
"SendMessageConfirmation": {
|
||||
"description": "SendMessageConfirmation struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ID": {
|
||||
"description": "Database ID",
|
||||
"type": "string",
|
||||
"example": "iAfZVVe2UQFNSG5BAjgYwa"
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"SendRequest": {
|
||||
"description": "SendRequest to send a message via HTTP",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"From"
|
||||
],
|
||||
"properties": {
|
||||
"Attachments": {
|
||||
"description": "Attachments",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Content",
|
||||
"Filename"
|
||||
],
|
||||
"properties": {
|
||||
"Content": {
|
||||
"description": "Base64-encoded string of the file content",
|
||||
"type": "string",
|
||||
"example": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA=="
|
||||
},
|
||||
"Filename": {
|
||||
"description": "Filename",
|
||||
"type": "string",
|
||||
"example": "AttachedFile.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bcc": {
|
||||
"description": "Bcc recipients email addresses only",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"jack@example.com"
|
||||
]
|
||||
},
|
||||
"Cc": {
|
||||
"description": "Cc recipients",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Email"
|
||||
],
|
||||
"properties": {
|
||||
"Email": {
|
||||
"description": "Email address",
|
||||
"type": "string",
|
||||
"example": "manager@example.com"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Optional name",
|
||||
"type": "string",
|
||||
"example": "Manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"From": {
|
||||
"description": "\"From\" recipient",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Email"
|
||||
],
|
||||
"properties": {
|
||||
"Email": {
|
||||
"description": "Email address",
|
||||
"type": "string",
|
||||
"example": "john@example.com"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Optional name",
|
||||
"type": "string",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"HTML": {
|
||||
"description": "Message body (HTML)",
|
||||
"type": "string",
|
||||
"example": "\u003cp style=\"font-family: arial\"\u003eMailpit is \u003cb\u003eawesome\u003c/b\u003e!\u003c/p\u003e"
|
||||
},
|
||||
"Headers": {
|
||||
"description": "Optional headers in {\"key\":\"value\"} format",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"X-IP": "1.2.3.4"
|
||||
}
|
||||
},
|
||||
"ReplyTo": {
|
||||
"description": "Optional Reply-To recipients",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Email"
|
||||
],
|
||||
"properties": {
|
||||
"Email": {
|
||||
"description": "Email address",
|
||||
"type": "string",
|
||||
"example": "secretary@example.com"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Optional name",
|
||||
"type": "string",
|
||||
"example": "Secretary"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Subject": {
|
||||
"description": "Subject",
|
||||
"type": "string",
|
||||
"example": "Mailpit message via the HTTP API"
|
||||
},
|
||||
"Tags": {
|
||||
"description": "Mailpit tags",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"Tag 1",
|
||||
"Tag 2"
|
||||
]
|
||||
},
|
||||
"Text": {
|
||||
"description": "Message body (text)",
|
||||
"type": "string",
|
||||
"example": "This is the text body"
|
||||
},
|
||||
"To": {
|
||||
"description": "\"To\" recipients",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Email"
|
||||
],
|
||||
"properties": {
|
||||
"Email": {
|
||||
"description": "Email address",
|
||||
"type": "string",
|
||||
"example": "jane@example.com"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Optional name",
|
||||
"type": "string",
|
||||
"example": "Jane Doe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"SpamAssassinResponse": {
|
||||
"description": "Result is a SpamAssassin result",
|
||||
"type": "object",
|
||||
@@ -1388,10 +1631,6 @@
|
||||
"description": "Response includes global web UI settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DisableHTMLCheck": {
|
||||
"description": "Whether the HTML check has been globally disabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"DuplicatesIgnored": {
|
||||
"description": "Whether messages with duplicate IDs are ignored",
|
||||
"type": "boolean"
|
||||
@@ -1400,14 +1639,14 @@
|
||||
"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"
|
||||
@@ -1430,16 +1669,15 @@
|
||||
"description": "Release request",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"to"
|
||||
"To"
|
||||
],
|
||||
"properties": {
|
||||
"to": {
|
||||
"To": {
|
||||
"description": "Array of email addresses to relay the message to",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "To",
|
||||
"example": [
|
||||
"user1@example.com",
|
||||
"user2@example.com"
|
||||
@@ -1452,23 +1690,21 @@
|
||||
"description": "Set read status request",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"IDs": {
|
||||
"description": "Array of message database IDs",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "IDs",
|
||||
"example": [
|
||||
"5dec4247-812e-4b77-9101-e25ad406e9ea",
|
||||
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
|
||||
]
|
||||
},
|
||||
"read": {
|
||||
"Read": {
|
||||
"description": "Read status",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"x-go-name": "Read",
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
@@ -1478,29 +1714,27 @@
|
||||
"description": "Set tags request",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tags",
|
||||
"ids"
|
||||
"Tags",
|
||||
"IDs"
|
||||
],
|
||||
"properties": {
|
||||
"ids": {
|
||||
"IDs": {
|
||||
"description": "Array of message database IDs",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "IDs",
|
||||
"example": [
|
||||
"5dec4247-812e-4b77-9101-e25ad406e9ea",
|
||||
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"Tags": {
|
||||
"description": "Array of tag names to set",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "Tags",
|
||||
"example": [
|
||||
"Tag 1",
|
||||
"Tag 2"
|
||||
@@ -1521,7 +1755,7 @@
|
||||
}
|
||||
},
|
||||
"BinaryResponse": {
|
||||
"description": "Binary data response inherits the attachment's content type",
|
||||
"description": "Binary data response inherits the attachment's content type.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1567,6 +1801,18 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/WebUIConfiguration"
|
||||
}
|
||||
},
|
||||
"jsonErrorResponse": {
|
||||
"description": "JSON error response",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/JSONErrorMessage"
|
||||
}
|
||||
},
|
||||
"sendMessageResponse": {
|
||||
"description": "Confirmation message for HTTP send API",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/SendMessageConfirmation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user