mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-06 22:57:02 +00:00
Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40f95555a | ||
|
|
a5558d97ce | ||
|
|
50c072ef4f | ||
|
|
561032f367 | ||
|
|
8f1b7b6ec0 | ||
|
|
be94385f38 | ||
|
|
61306e1ae4 | ||
|
|
dac9fcf735 | ||
|
|
3528bc8da7 | ||
|
|
cb3300212f | ||
|
|
f377414c3b | ||
|
|
a2db203a08 | ||
|
|
b1eb58c9c8 | ||
|
|
76b7e74049 | ||
|
|
ed0caa0081 | ||
|
|
45e67b5cac | ||
|
|
0c63c29769 | ||
|
|
f4d6dd5c39 | ||
|
|
496bf17db7 | ||
|
|
86b5524217 | ||
|
|
cba9f0043c | ||
|
|
a1b08ea2bc | ||
|
|
3d6d899a6d | ||
|
|
9687329fc1 | ||
|
|
04410ff463 | ||
|
|
a29b969e61 | ||
|
|
8425780ccd | ||
|
|
8331e11f7f | ||
|
|
d7df895261 | ||
|
|
e2fab49873 | ||
|
|
a95bc3d29f | ||
|
|
f278933bb9 | ||
|
|
4d86297169 | ||
|
|
2a6ab0476b | ||
|
|
b2ffb7476d | ||
|
|
338f205234 | ||
|
|
168049faf9 | ||
|
|
2a1a5ae852 | ||
|
|
e30754a167 | ||
|
|
fd46d4076b | ||
|
|
7703d09919 | ||
|
|
b3e7995342 | ||
|
|
c8937e218f | ||
|
|
82cfd605e5 | ||
|
|
d67feec713 | ||
|
|
9f4908d11d | ||
|
|
13027bf10b | ||
|
|
37c0558ddd | ||
|
|
9d205cfdcc | ||
|
|
e01a0f8f4b | ||
|
|
ebf8f3568b | ||
|
|
572bda80a2 | ||
|
|
23fee8e4e1 | ||
|
|
b2f4acb7ed | ||
|
|
2ea92d1b7e | ||
|
|
0af5d184f5 | ||
|
|
4ad6a4553c | ||
|
|
b5734691e8 | ||
|
|
e78bc79f5e | ||
|
|
0fbb9463d4 | ||
|
|
4c954e655c | ||
|
|
16fbb728a4 | ||
|
|
b27a28cbf5 | ||
|
|
b1c745fb32 | ||
|
|
ccd35c7dc9 | ||
|
|
11a9014241 | ||
|
|
93c9eb3fbf | ||
|
|
68f2a3189e | ||
|
|
d57aa9b37e | ||
|
|
14f1a44c7a | ||
|
|
3e7d4f8175 | ||
|
|
22cae16e00 | ||
|
|
6e44691f6d | ||
|
|
aabb2acab9 | ||
|
|
0277f4e944 | ||
|
|
7c31b3d0c0 | ||
|
|
c7f3937cb2 | ||
|
|
5a3448accf | ||
|
|
53b55ec320 | ||
|
|
0ff73b7df6 | ||
|
|
5666462f29 | ||
|
|
bc23e6336d | ||
|
|
6d115ceb86 | ||
|
|
a33d0c9d07 | ||
|
|
f08a959545 | ||
|
|
249a02b71a | ||
|
|
b698e037bf | ||
|
|
8dab8abde4 | ||
|
|
8c2e5d856a | ||
|
|
1afd138cc5 | ||
|
|
c4e0e651a3 | ||
|
|
0b11ce26ab | ||
|
|
bc7d7f901d | ||
|
|
a7fac05209 | ||
|
|
657cada916 | ||
|
|
ea219e5ec9 | ||
|
|
8f79fcd0d5 | ||
|
|
61cff513cb | ||
|
|
13caeb4f5b | ||
|
|
3f2457cc6a | ||
|
|
2c94c32722 | ||
|
|
d448211653 | ||
|
|
ccef1ae20d | ||
|
|
0f24496ee2 | ||
|
|
2743e2e0cb | ||
|
|
4be92633f2 | ||
|
|
5675abef84 | ||
|
|
bd47c19058 | ||
|
|
47c6062b1c | ||
|
|
48a1f6b877 | ||
|
|
10c20dd00f | ||
|
|
57d32c6627 | ||
|
|
4ba1343184 | ||
|
|
e4da814ece | ||
|
|
324a0ac037 | ||
|
|
e1b02be9ba | ||
|
|
31ec6681a7 | ||
|
|
e2c3256f0c | ||
|
|
2420aa7c2a | ||
|
|
009d02816f | ||
|
|
5131b6a0cc | ||
|
|
d2070e1ee1 | ||
|
|
405babda7b | ||
|
|
882adeebe3 | ||
|
|
f8efda0149 | ||
|
|
d61304a854 | ||
|
|
4ff9fdf298 | ||
|
|
51e29ba90a | ||
|
|
9ab289a6c9 | ||
|
|
2c945be5b9 | ||
|
|
f9a185da46 | ||
|
|
73a993492e | ||
|
|
a56fd1f53d | ||
|
|
073ddd33d5 | ||
|
|
f142893d58 | ||
|
|
bd026bef8c | ||
|
|
26e8706eb4 | ||
|
|
ff8cd229ca | ||
|
|
2c326acf08 | ||
|
|
1aed5fda5a | ||
|
|
9a4982e646 | ||
|
|
a64950ddea | ||
|
|
7f4cd90c03 | ||
|
|
56f1138f8e | ||
|
|
bd5c450294 | ||
|
|
54a72e8e1e | ||
|
|
069967f502 | ||
|
|
4ee3ba4753 | ||
|
|
84e46e6604 | ||
|
|
2048f15bbf | ||
|
|
93761b6f53 | ||
|
|
2a0853d21a | ||
|
|
dc1a16ed5c | ||
|
|
f95147fd83 | ||
|
|
c84bfc3330 | ||
|
|
b22eccd88c | ||
|
|
1c8f0bf136 | ||
|
|
48195b004e | ||
|
|
32185e3abe | ||
|
|
be1d2bcb28 | ||
|
|
259d71122b | ||
|
|
b37a24fdcf | ||
|
|
f598c9adbb | ||
|
|
aaa873ed68 | ||
|
|
fb8b24cc28 | ||
|
|
7d55e20e85 | ||
|
|
e98109a238 | ||
|
|
3cec8bfab8 | ||
|
|
4f2324a367 | ||
|
|
ac60ed62ae | ||
|
|
65327b975b | ||
|
|
ba42cac2ad | ||
|
|
5fc025b1a5 | ||
|
|
48bef8d7ac |
4
.github/workflows/close-stale-issues.yml
vendored
4
.github/workflows/close-stale-issues.yml
vendored
@@ -10,11 +10,11 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9.0.0
|
||||
- uses: actions/stale@v9.1.0
|
||||
with:
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
exempt-issue-labels: "enhancement,bug,javascript,docker"
|
||||
exempt-issue-labels: "enhancement,bug,awaiting feedback"
|
||||
stale-issue-label: "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."
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.21.x]
|
||||
go-version: ['1.23']
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test -p 1 ./internal/storage ./server ./server/pop3 ./internal/tools ./internal/html2text -v
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/linkcheck -v
|
||||
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
@@ -44,6 +44,6 @@ jobs:
|
||||
# validate the swagger file
|
||||
- name: Validate OpenAPI definition
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: char0n/swagger-editor-validate@v1
|
||||
uses: swaggerexpert/swagger-editor-validate@v1
|
||||
with:
|
||||
definition-file: server/ui/api/v1/swagger.json
|
||||
|
||||
217
CHANGELOG.md
217
CHANGELOG.md
@@ -2,6 +2,223 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.22.2]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies / esbuild
|
||||
- Update Go dependencies
|
||||
- Enable browser cache for embedded web UI assets
|
||||
- Replace http.FileServer with custom controller to correctly encode gzipped error responses for embed.FS
|
||||
|
||||
### Fix
|
||||
- Add missing "latest" route to message attachment API endpoint ([#437](https://github.com/axllent/mailpit/issues/437))
|
||||
- Remove recursive HTML regeneration in embedded HTML view ([#434](https://github.com/axllent/mailpit/issues/434))
|
||||
|
||||
|
||||
## [v1.22.1]
|
||||
|
||||
### Feature
|
||||
- Add optional query parameter for HTML message iframe embedding ([#434](https://github.com/axllent/mailpit/issues/434))
|
||||
- Add optional UI setting to skip "Delete all" & "Mark all read" confirmation dialogs([#428](https://github.com/axllent/mailpit/issues/428))
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Add API CORS policy to HTML preview routes ([#434](https://github.com/axllent/mailpit/issues/434))
|
||||
- Bump actions/stale from 9.0.0 to 9.1.0 ([#432](https://github.com/axllent/mailpit/issues/432))
|
||||
|
||||
|
||||
## [v1.22.0]
|
||||
|
||||
### Feature
|
||||
- SMTP auto-forwarding option ([#414](https://github.com/axllent/mailpit/issues/414))
|
||||
- Option to override the From email address in SMTP relay configuration ([#414](https://github.com/axllent/mailpit/issues/414))
|
||||
- Add Chaos functionality to test integration handling of SMTP error responses ([#402](https://github.com/axllent/mailpit/issues/402), [#110](https://github.com/axllent/mailpit/issues/110), [#144](https://github.com/axllent/mailpit/issues/144) & [#268](https://github.com/axllent/mailpit/issues/268))
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Fix
|
||||
- Update command `npm run update-caniemail` save path ([#422](https://github.com/axllent/mailpit/issues/422))
|
||||
- Correct date formatting in TestMakeHeaders
|
||||
|
||||
|
||||
## [v1.21.8]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Fix
|
||||
- **db:** Remove unused FOREIGN KEY REFERENCES in message_tags table ([#374](https://github.com/axllent/mailpit/issues/374))
|
||||
|
||||
|
||||
## [v1.21.7]
|
||||
|
||||
### Chore
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Bump Go version for automated testing
|
||||
- Move smtpd & pop3 modules to internal
|
||||
- Stricter SMTP 'MAIL FROM' & 'RCPT TO' handling ([#409](https://github.com/axllent/mailpit/issues/409))
|
||||
- Display "To" details in mobile messages list
|
||||
- Display "From" details in message sidebar (desktop) ([#403](https://github.com/axllent/mailpit/issues/403))
|
||||
|
||||
### Fix
|
||||
- Ignore unsupported optional SMTP 'MAIL FROM' parameters ([#407](https://github.com/axllent/mailpit/issues/407))
|
||||
- Prevent splitting multi-byte characters in message snippets ([#404](https://github.com/axllent/mailpit/issues/404))
|
||||
|
||||
### Testing
|
||||
- Add smtpd tests
|
||||
|
||||
|
||||
## [v1.21.6]
|
||||
|
||||
### Feature
|
||||
- Include Mailpit label (if set) in webhook HTTP header ([#400](https://github.com/axllent/mailpit/issues/400))
|
||||
- Add support for sending inline attachments via HTTP API ([#399](https://github.com/axllent/mailpit/issues/399))
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
|
||||
### Fix
|
||||
- Message view not updating when deleting messages from search ([#395](https://github.com/axllent/mailpit/issues/395))
|
||||
|
||||
|
||||
## [v1.21.5]
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Make symlink detection more specific to contain "sendmail" in the name ([#391](https://github.com/axllent/mailpit/issues/391))
|
||||
|
||||
|
||||
## [v1.21.4]
|
||||
|
||||
### Bugfix
|
||||
- Fix external CSS stylesheet loading in HTML preview ([#388](https://github.com/axllent/mailpit/issues/388))
|
||||
|
||||
|
||||
## [v1.21.3]
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Minor UI tweaks
|
||||
- Mute Dart Sass deprecation notices
|
||||
- Update node dependencies
|
||||
- Upgrade Alpine packages on Docker build
|
||||
- Add swagger examples & API code restructure
|
||||
|
||||
|
||||
## [v1.21.2]
|
||||
|
||||
### Feature
|
||||
- Add additional ignored flags to sendmail ([#384](https://github.com/axllent/mailpit/issues/384))
|
||||
|
||||
### Chore
|
||||
- Remove legacy Tags column from message DB table
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
|
||||
### Fix
|
||||
- Fix browser notification request on Edge ([#89](https://github.com/axllent/mailpit/issues/89))
|
||||
|
||||
|
||||
## [v1.21.1]
|
||||
|
||||
### Feature
|
||||
- Add ability to search by size smaller or larger than a value (eg: `larger:1M` / `smaller:2.5M`)
|
||||
- Add ability to search for messages containing inline images (`has:inline`)
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Separate attachments and inline images in download nav and badges ([#379](https://github.com/axllent/mailpit/issues/379))
|
||||
|
||||
|
||||
## [v1.21.0]
|
||||
|
||||
### Feature
|
||||
- Experimental Unix socket support for HTTPD & SMTPD ([#373](https://github.com/axllent/mailpit/issues/373))
|
||||
|
||||
### Fix
|
||||
- Allow multiple item selection on macOS with Cmd-click ([#378](https://github.com/axllent/mailpit/issues/378))
|
||||
|
||||
|
||||
## [v1.20.7]
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
|
||||
### Fix
|
||||
- SQL error deleting a tag while using tenant-id ([#374](https://github.com/axllent/mailpit/issues/374))
|
||||
|
||||
### Testing
|
||||
- Add tenantIDs to tests
|
||||
|
||||
|
||||
## [v1.20.6]
|
||||
|
||||
### Chore
|
||||
- Bump Go compile version to 1.23
|
||||
- Update node modules
|
||||
- Update swagger file tests
|
||||
- Code cleanup
|
||||
- Update Go dependencies
|
||||
- Update minimum Go version (1.22.0)
|
||||
- Update node dependencies
|
||||
|
||||
|
||||
## [v1.20.5]
|
||||
|
||||
### Chore
|
||||
- Update node modules
|
||||
- Use consistent margins for Mailpit label if set
|
||||
- Improve tag detection in UI
|
||||
- Improve link detection in the HTML preview
|
||||
|
||||
### Fix
|
||||
- Use correct parameter order in SpamAssassin socket detection ([#364](https://github.com/axllent/mailpit/issues/364))
|
||||
|
||||
|
||||
## [v1.20.4]
|
||||
|
||||
### Chore
|
||||
- Update Go modules
|
||||
- Update node modules
|
||||
- Upgrade vue-css-donut-chart & related charts
|
||||
|
||||
### Fix
|
||||
- Relax URL detection in link check tool ([#357](https://github.com/axllent/mailpit/issues/357))
|
||||
|
||||
|
||||
## [v1.20.3]
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
- Update node dependencies
|
||||
- Update Go dependencies
|
||||
- Do not recenter selected messages in sidebar on every new message
|
||||
|
||||
### Fix
|
||||
- Disable automatic HTML/Text character detection when charset is provided ([#348](https://github.com/axllent/mailpit/issues/348))
|
||||
|
||||
|
||||
## [v1.20.2]
|
||||
|
||||
### Feature
|
||||
- Web UI notifications of smtpd & POP3 errors ([#347](https://github.com/axllent/mailpit/issues/347))
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
- Add debug database storage logging
|
||||
- Add smtpd server logging in the CLI ([#347](https://github.com/axllent/mailpit/issues/347))
|
||||
|
||||
|
||||
## [v1.20.1]
|
||||
|
||||
### Chore
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY . /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git npm && \
|
||||
RUN apk upgrade && apk add git npm && \
|
||||
npm install && npm run package && \
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
|
||||
|
||||
@@ -21,7 +21,7 @@ LABEL org.opencontainers.image.title="Mailpit" \
|
||||
|
||||
COPY --from=builder /mailpit /mailpit
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
RUN apk upgrade --no-cache && apk add --no-cache tzdata
|
||||
|
||||
EXPOSE 1025/tcp 1110/tcp 8025/tcp
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- [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
|
||||
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
|
||||
- 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)
|
||||
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
|
||||
- `List-Unsubscribe` syntax validation
|
||||
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
sendmail "github.com/axllent/mailpit/sendmail/cmd"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,7 +34,6 @@ The --recent flag will only consider files with a modification date within the l
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
p := message.NewPrinter(language.English)
|
||||
|
||||
for _, a := range args {
|
||||
err := filepath.Walk(a,
|
||||
@@ -108,7 +105,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
}
|
||||
}
|
||||
|
||||
err = smtp.SendMail(sendmail.SMTPAddr, nil, returnPath, recipients, body)
|
||||
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
|
||||
return nil
|
||||
@@ -117,8 +114,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
count++
|
||||
total++
|
||||
if count%100 == 0 {
|
||||
formatted := p.Sprintf("%d", total)
|
||||
logger.Log().Infof("[%s] 100 messages in %s", formatted, time.Since(per100start))
|
||||
logger.Log().Infof("[%s] 100 messages in %s", format(total), time.Since(per100start))
|
||||
|
||||
per100start = time.Now()
|
||||
}
|
||||
@@ -149,3 +145,29 @@ func isFile(path string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Format a an integer 10000 => 10,000
|
||||
func format(n int) string {
|
||||
in := fmt.Sprintf("%d", n)
|
||||
numOfDigits := len(in)
|
||||
if n < 0 {
|
||||
numOfDigits-- // First character is the - sign (not a digit)
|
||||
}
|
||||
numOfCommas := (numOfDigits - 1) / 3
|
||||
|
||||
out := make([]byte, len(in)+numOfCommas)
|
||||
if n < 0 {
|
||||
in, out[0] = in[1:], '-'
|
||||
}
|
||||
|
||||
for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
|
||||
out[j] = in[i]
|
||||
if i == 0 {
|
||||
return string(out)
|
||||
}
|
||||
if k++; k == 3 {
|
||||
j, k = j-1, 0
|
||||
out[j] = ','
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
cmd/root.go
41
cmd/root.go
@@ -9,10 +9,11 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"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"
|
||||
)
|
||||
@@ -122,6 +123,13 @@ func init() {
|
||||
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)")
|
||||
|
||||
// SMTP forwarding
|
||||
rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
|
||||
|
||||
// Chaos
|
||||
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
|
||||
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
|
||||
|
||||
// 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)")
|
||||
@@ -208,7 +216,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
|
||||
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
|
||||
@@ -231,7 +239,7 @@ func initConfigFromEnv() {
|
||||
}
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
|
||||
logger.Log().Errorf(err.Error())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
|
||||
config.SMTPAuthAcceptAny = true
|
||||
@@ -278,16 +286,38 @@ func initConfigFromEnv() {
|
||||
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.OverrideFrom = os.Getenv("MP_SMTP_RELAY_OVERRIDE_FROM")
|
||||
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
|
||||
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
|
||||
config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
|
||||
if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
|
||||
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
|
||||
}
|
||||
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
|
||||
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
|
||||
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
|
||||
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
|
||||
config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
|
||||
config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
|
||||
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
|
||||
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
|
||||
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
|
||||
|
||||
// Chaos
|
||||
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
|
||||
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
|
||||
|
||||
// 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())
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
|
||||
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
|
||||
@@ -305,6 +335,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
|
||||
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
|
||||
}
|
||||
|
||||
// Demo mode
|
||||
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")
|
||||
}
|
||||
|
||||
// load deprecated settings from environment and warn
|
||||
|
||||
@@ -12,13 +12,13 @@ var sendmailCmd = &cobra.Command{
|
||||
Use: "sendmail [flags] [recipients]",
|
||||
Short: "A sendmail command replacement for Mailpit",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
sendmail.Run()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(sendmailCmd)
|
||||
var ignored string
|
||||
|
||||
// print out manual help screen
|
||||
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
|
||||
@@ -27,10 +27,13 @@ func init() {
|
||||
// multi-letter single-dash variables (-bs)
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
|
||||
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "ignored-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "ignored-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
|
||||
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
|
||||
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-i", "i", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-o", "o", false, "Ignored")
|
||||
sendmailCmd.Flags().BoolP("ignored-t", "t", false, "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-name", "F", "", "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-bits", "B", "", "Ignored")
|
||||
sendmailCmd.Flags().StringVarP(&ignored, "ignored-errors", "e", "", "Ignored")
|
||||
}
|
||||
|
||||
228
config/config.go
228
config/config.go
@@ -5,19 +5,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -114,22 +112,12 @@ var (
|
||||
// including x-tags & plus-addresses
|
||||
TagsDisable string
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
|
||||
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
|
||||
SMTPRelayConfig SMTPRelayConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
|
||||
ReleaseEnabled = false
|
||||
|
||||
@@ -143,6 +131,22 @@ var (
|
||||
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
|
||||
SMTPRelayMatchingRegexp *regexp.Regexp
|
||||
|
||||
// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfigFile string
|
||||
|
||||
// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
|
||||
SMTPForwardConfig SMTPForwardConfigStruct
|
||||
|
||||
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
|
||||
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
|
||||
SMTPStrictRFCHeaders bool
|
||||
|
||||
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
|
||||
SMTPAllowedRecipients string
|
||||
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
|
||||
POP3Listen = "[::]:1110"
|
||||
|
||||
@@ -176,8 +180,14 @@ var (
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
|
||||
// ChaosTriggers are parsed and set in the chaos module
|
||||
ChaosTriggers string
|
||||
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// DemoMode disables SMTP relay, link checking & HTTP send functionality
|
||||
DemoMode = false
|
||||
)
|
||||
|
||||
// AutoTag struct for auto-tagging
|
||||
@@ -197,6 +207,7 @@ type SMTPRelayConfigStruct struct {
|
||||
Password string `yaml:"password"` // plain
|
||||
Secret string `yaml:"secret"` // cram-md5
|
||||
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
@@ -206,6 +217,21 @@ type SMTPRelayConfigStruct struct {
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
}
|
||||
|
||||
// SMTPForwardConfigStruct struct for parsing yaml & storing variables
|
||||
type SMTPForwardConfigStruct struct {
|
||||
To string `yaml:"to"` // comma-separated list of email addresses
|
||||
Host string `yaml:"host"` // SMTP host
|
||||
Port int `yaml:"port"` // SMTP port
|
||||
STARTTLS bool `yaml:"starttls"` // whether to use STARTTLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication
|
||||
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
|
||||
OverrideFrom string `yaml:"override-from"` // allow overriding of the from address
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
func VerifyConfig() error {
|
||||
cssFontRestriction := "*"
|
||||
@@ -230,21 +256,16 @@ func VerifyConfig() error {
|
||||
return err
|
||||
}
|
||||
|
||||
TenantID = tools.Normalize(TenantID)
|
||||
TenantID = DBTenantID(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+$`)
|
||||
if !re.MatchString(SMTPListen) {
|
||||
if _, _, isSocket := tools.UnixSocket(SMTPListen); !isSocket && !re.MatchString(SMTPListen) {
|
||||
return errors.New("[smtp] bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
if !re.MatchString(HTTPListen) {
|
||||
if _, _, isSocket := tools.UnixSocket(HTTPListen); !isSocket && !re.MatchString(HTTPListen) {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
@@ -346,6 +367,14 @@ func VerifyConfig() error {
|
||||
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
|
||||
}
|
||||
|
||||
if err := parseChaosTriggers(); err != nil {
|
||||
return fmt.Errorf("[chaos] %s", err.Error())
|
||||
}
|
||||
|
||||
if chaos.Enabled {
|
||||
logger.Log().Info("[chaos] is enabled")
|
||||
}
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
@@ -467,159 +496,20 @@ func VerifyConfig() error {
|
||||
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
// separate forwarding config validation to account for environment variables
|
||||
if err := validateForwardConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
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
|
||||
}
|
||||
if DemoMode {
|
||||
MaxMessages = 1000
|
||||
// this deserves a warning
|
||||
logger.Log().Info("demo mode enabled")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
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")
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
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 err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
51
config/utils.go
Normal file
51
config/utils.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
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 err != nil || os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidURL(s string) bool {
|
||||
u, err := url.ParseRequestURI(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(u.Scheme, "http")
|
||||
}
|
||||
|
||||
// DBTenantID converts a tenant ID to a DB-friendly value if set
|
||||
func DBTenantID(s string) string {
|
||||
s = tools.Normalize(s)
|
||||
if s != "" {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
|
||||
s = re.ReplaceAllString(s, "_")
|
||||
if !strings.HasSuffix(s, "_") {
|
||||
s = s + "_"
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
282
config/validators.go
Normal file
282
config/validators.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[relay] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.Host == "" {
|
||||
return errors.New("[relay] host not set")
|
||||
}
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
if SMTPRelayConfig.RecipientAllowlist != "" {
|
||||
logger.Log().Warn("[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
|
||||
}
|
||||
|
||||
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
|
||||
|
||||
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
|
||||
SMTPRelayConfig.Auth = "none"
|
||||
} else if SMTPRelayConfig.Auth == "plain" {
|
||||
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
|
||||
return fmt.Errorf("[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("[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("[relay] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.AllowedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.AllowedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.BlockedRecipients != "" {
|
||||
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
|
||||
}
|
||||
|
||||
SMTPRelayConfig.BlockedRecipientsRegexp = re
|
||||
logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
|
||||
}
|
||||
|
||||
if SMTPRelayConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
SMTPRelayConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
ReleaseEnabled = true
|
||||
|
||||
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPForwardConfigFile (if set)
|
||||
func parseForwardConfig(c string) error {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c = filepath.Clean(c)
|
||||
|
||||
if !isFile(c) {
|
||||
return fmt.Errorf("[forward] configuration not found or readable: %s", c)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return errors.New("[forward] host not set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate the SMTPForwardConfig (if Host is set)
|
||||
func validateForwardConfig() error {
|
||||
if SMTPForwardConfig.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.Port == 0 {
|
||||
SMTPForwardConfig.Port = 25 // default
|
||||
}
|
||||
|
||||
SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
|
||||
|
||||
if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
|
||||
SMTPForwardConfig.Auth = "none"
|
||||
} else if SMTPForwardConfig.Auth == "plain" {
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
|
||||
}
|
||||
} else if SMTPForwardConfig.Auth == "login" {
|
||||
SMTPForwardConfig.Auth = "login"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
|
||||
return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
|
||||
}
|
||||
} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
|
||||
SMTPForwardConfig.Auth = "cram-md5"
|
||||
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
|
||||
return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
|
||||
}
|
||||
|
||||
if SMTPForwardConfig.To == "" {
|
||||
return errors.New("[forward] To addresses missing")
|
||||
}
|
||||
|
||||
to := []string{}
|
||||
addresses := strings.Split(SMTPForwardConfig.To, ",")
|
||||
for _, a := range addresses {
|
||||
a = strings.TrimSpace(a)
|
||||
m, err := mail.ParseAddress(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
|
||||
}
|
||||
to = append(to, m.Address)
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return errors.New("[forward] no valid To addresses found")
|
||||
}
|
||||
|
||||
// overwrite the To field with the cleaned up list
|
||||
SMTPForwardConfig.To = strings.Join(to, ",")
|
||||
|
||||
if SMTPForwardConfig.OverrideFrom != "" {
|
||||
m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
|
||||
}
|
||||
|
||||
SMTPForwardConfig.OverrideFrom = m.Address
|
||||
}
|
||||
|
||||
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseChaosTriggers() error {
|
||||
if ChaosTriggers == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
|
||||
|
||||
parts := strings.Split(ChaosTriggers, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if !re.MatchString(p) {
|
||||
return fmt.Errorf("invalid argument: %s", p)
|
||||
}
|
||||
|
||||
matches := re.FindAllStringSubmatch(p, 1)
|
||||
key := matches[0][1]
|
||||
errorCode, err := strconv.Atoi(matches[0][2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probability, err := strconv.Atoi(matches[0][3])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := chaos.Set(key, errorCode, probability); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -20,7 +20,13 @@ const ctx = await esbuild.context(
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [pluginVue(), sassPlugin()],
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ['import'],
|
||||
quietDeps: true,
|
||||
})
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
|
||||
52
go.mod
52
go.mod
@@ -1,45 +1,43 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.21.0
|
||||
go 1.23
|
||||
|
||||
toolchain go1.22.1
|
||||
toolchain go1.23.2
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.2.0
|
||||
github.com/klauspost/compress v1.17.9
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/kovidgoyal/imaging v1.6.3
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
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/lithammer/shortuuid/v4 v4.2.0
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.2
|
||||
github.com/vanng822/go-premailer v1.21.0
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/text v0.16.0
|
||||
golang.org/x/time v0.6.0
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/tg123/go-htpasswd v1.2.3
|
||||
github.com/vanng822/go-premailer v1.23.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/text v0.22.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.31.1
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // 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/kr/pretty v0.3.0 // indirect
|
||||
@@ -54,14 +52,12 @@ require (
|
||||
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.25.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||
modernc.org/libc v1.56.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
modernc.org/libc v1.61.12 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
|
||||
173
go.sum
173
go.sum
@@ -1,10 +1,11 @@
|
||||
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/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/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
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=
|
||||
@@ -18,16 +19,16 @@ 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/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/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
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/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -36,16 +37,14 @@ 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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
|
||||
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
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=
|
||||
@@ -57,8 +56,8 @@ 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/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
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=
|
||||
@@ -67,8 +66,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
|
||||
github.com/mattn/go-runewidth v0.0.16/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.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
|
||||
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 h1:XMG5DklHoioVYysfYglOB7vRBg/LOUJZy2mq2QyedLg=
|
||||
github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62/go.mod h1:niAM5cni0I/47IFA995xQfeK58Mkbb7FHJjacY4OGQg=
|
||||
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=
|
||||
@@ -79,8 +78,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -89,103 +86,137 @@ 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/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/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.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/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=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tg123/go-htpasswd v1.2.3 h1:ALR6ZBIc2m9u70m+eAWUFt5p43ISbIvAvRFYzZPTOY8=
|
||||
github.com/tg123/go-htpasswd v1.2.3/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
|
||||
github.com/unrolled/render v1.7.0/go.mod h1:LwQSeDhjml8NLjIO9GJO1/1qpFJxtfVIpzxXKjfVkoI=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
|
||||
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
|
||||
github.com/vanng822/go-premailer v1.23.0 h1:vZp2wuz1jb4q/DurUV18VGjXWtTFYZHwTCw2EAWKO74=
|
||||
github.com/vanng822/go-premailer v1.23.0/go.mod h1:0+z0UJ6ZGQatzkWlaQNl50M7fLz5f6FcP8V2p0oie88=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3 h1:qNgPs5exUA+G0C96DrPwNrvLSj7GT/9D+3WMWUcUg34=
|
||||
golang.org/x/exp v0.0.0-20250207012021-f9890c6ad9f3/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
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.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.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.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
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/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
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.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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.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.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
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=
|
||||
@@ -195,29 +226,27 @@ 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=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.20.6 h1:Df/8U75vr6+vqYyBCSGXinSbkl/0iLjWEIYGYgYhJOk=
|
||||
modernc.org/ccgo/v4 v4.20.6/go.mod h1:mL6ov9HLsSs3WlPorlPuWFO5u9+xVNTqW72uClt3pRI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
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.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.56.0 h1:O9Xq2c2MnP1bU69vbPX5jHQyQfElMprUR94GlBljRaw=
|
||||
modernc.org/libc v1.56.0/go.mod h1:s9jnOo7LcO1G9+nGj4GY/+Fj8+teenJoC/WfVxnSd+M=
|
||||
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.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/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.31.1 h1:XVU0VyzxrYHlBhIs1DiEgSl0ZtdnPtbLVy8hSkzxGrs=
|
||||
modernc.org/sqlite v1.31.1/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.12 h1:Fsnh0A7XLXylYNwIOJmKux9PhnfrIvMaMnjuyJ1t/f4=
|
||||
modernc.org/libc v1.61.12/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -78,5 +78,6 @@ func clean(text string) string {
|
||||
}, text)
|
||||
|
||||
text = re.ReplaceAllString(text, " ")
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2024-07-29 15:27:49 +0000",
|
||||
"last_update_date":"2024-11-29 15:25:23 +0000",
|
||||
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
|
||||
"data":[
|
||||
{
|
||||
@@ -627,6 +627,22 @@
|
||||
"notes_by_num":{"1":"The `caption-side` property in CSS is supported but the `<caption>` HTML element is not."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-clear",
|
||||
"title":"clear",
|
||||
"description":"Sets whether an element must be moved below (cleared) floating elements that precede it.",
|
||||
"url":"https://www.caniemail.com/features/css-clear/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-09-06",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
|
||||
"stats":{"apple-mail":{"macos":{"11":"a #1","12":"y"},"ios":{"14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"13":"a #2","16":"y"},"android":{"2024-09":"a #1"},"mobile-webmail":{"2024-09":"u"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"u"},"outlook-com":{"2024-09":"y"},"ios":{"2024-09":"y"},"android":{"2024-09":"a #1"}},"yahoo":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"aol":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"a #1"},"android":{"2024-09":"a #1"}},"samsung-email":{"android":{"2024-09":"y"}},"sfr":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"thunderbird":{"macos":{"2024-09":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"y"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}},"laposte":{"desktop-webmail":{"2024-09":"u"}},"gmx":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"web-de":{"desktop-webmail":{"2024-09":"a #1"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-09":"u"},"android":{"2024-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-clip-path",
|
||||
"title":"clip-path",
|
||||
@@ -691,6 +707,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-comments",
|
||||
"title":"CSS comments",
|
||||
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
|
||||
"url":"https://www.caniemail.com/features/css-comments/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-04-25",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"a #2"},"mobile-webmail":{"2024-04":"a #4"}},"orange":{"desktop-webmail":{"2024-08":"n #6"},"ios":{"2024-08":"n #6"},"android":{"2024-08":"n #6"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"a #1"},"android":{"2024-04":"a #1"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-08":"a #5"},"ios":{"2024-08":"a #5"},"android":{"2024-08":"a #5"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #3"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #5"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. The first <head> in the HTML is removed, so comment needs to be in the `<style>` tag of a second `<head>` element.","2":"Partial. `<style>` tag not supported with non-google account. Comment inside `style:` attribute works.","3":"Partial. Comment inside `<style>` tag works. Comment inside `style` attribute strips the whole attribute.","4":"Partial. `<style>` tag not supported. Comment inside `style:` attribute works.","5":"Partial. Comment inside `style` attribute works.","6":"Not supported. The entire rule is removed within a `<style> element. The entire inline `style` attribute is removed."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-conic-gradient",
|
||||
"title":"conic-gradient()",
|
||||
@@ -787,6 +819,22 @@
|
||||
"notes_by_num":{"1":"Partial. `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` are not supported with non Google accounts.","2":"Partial. `inline-flex`, `inline-grid`, `flex`, `grid`, `flow-root`, `contents`, `inline flow-root`, `inline flex`, `inline grid`, `initial`, `revert`, `unset` values are not supported.","3":"Buggy. Only the first value is kept with the two-value syntax.","4":"Buggy. `display:none` does not inherit to inner tables.","5":"Partial. Only supports `display:none` (but not on `<img>`).","6":"Partial. `flow-root`, `inline-flex`, `inline-grid`, `inline flow`, `contents`, `revert` are not supported.","7":"Partial. Two-value syntax are combined into a single one with a dash."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-empty-cells",
|
||||
"title":"empty-cells",
|
||||
"description":"Sets whether borders and backgrounds appear around `<table>` cells that have no visible content.",
|
||||
"url":"https://www.caniemail.com/features/css-empty-cells/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"blank",
|
||||
"last_test_date":"2024-08-23",
|
||||
"test_url":"https://www.caniemail.com/tests/css-empty-cells.html",
|
||||
"test_results_url":"https://testi.at/proj/kgl7t57xs8jxueze0v8",
|
||||
"stats":{"apple-mail":{"macos":{"2024-08":"y"},"ios":{"2024-08":"y"}},"gmail":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"},"mobile-webmail":{"2024-08":"u"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"y"},"outlook-com":{"2024-08":"y"},"ios":{"2024-08":"y"},"android":{"2024-08":"y"}},"yahoo":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"y"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"n"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-filter",
|
||||
"title":"filter",
|
||||
@@ -843,12 +891,12 @@
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2019-02-28",
|
||||
"last_test_date":"2024-05-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n","2023-01":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"test_results_url":"https://testi.at/proj/gyjkc98dtyzxfd3bhz",
|
||||
"stats":{"apple-mail":{"macos":{"11.7":"a #2","12.4":"y"},"ios":{"14":"a #2","15":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"y","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n #1","2010":"n #1","2013":"n #1","2016":"n #1","2019":"n #1"},"windows-mail":{"2019-02":"n #1"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"n","2023-01":"y","2024-05":"a #2"}},"aol":{"desktop-webmail":{"2019-02":"y","2024-05":"a #2"},"ios":{"2019-02":"y","2024-05":"a #2"},"android":{"2019-02":"y","2024-05":"a #2"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y","2024-05":"a #2"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect."}
|
||||
"notes_by_num":{"1":"Not supported. `table` and `img` elements can use an `align` attribute to get a similar effect.","2":"Partial. Logical property values `inline-start` and `inline-end` are not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -925,7 +973,7 @@
|
||||
"keywords":null,
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text.html",
|
||||
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1","2020-12":"y"},"android":{"2019-02":"a #1","2020-12":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2019-02":"y"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
|
||||
@@ -947,6 +995,22 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-function-light-dark",
|
||||
"title":"light-dark()",
|
||||
"description":"Enables setting two colors (one for light and the other for dark mode) for a property.",
|
||||
"url":"https://www.caniemail.com/features/css-function-light-dark/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"dark, light",
|
||||
"last_test_date":"2024-08-14",
|
||||
"test_url":"https://www.caniemail.com/tests/css-function-light-dark.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/Lai13xyIE95H6jo1BBs6ay0f3RvJdPL344S3j3M7FbeU4/list",
|
||||
"stats":{"apple-mail":{"macos":{"16.0":"y #1"},"ios":{"17.5.1":"y"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-08":"n"},"macos":{"16.88":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"samsung-email":{"android":{"6.0":"u"}},"sfr":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"a #1 #2"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"115.10.1":"n","128.1.0":"y"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"u"}},"protonmail":{"desktop-webmail":{"2024-08":"a #2"},"ios":{"2024-08":"y #1"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"a #2"}},"mail-ru":{"desktop-webmail":{"2024-08":"a #2"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"a #2"}},"free-fr":{"desktop-webmail":{"2024-08":"n"}},"t-online-de":{"desktop-webmail":{"2024-08":"a #2"}},"gmx":{"desktop-webmail":{"2024-08":"n"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Only supported if you’ve updated your OS with Safari 17.5 or later.","2":"Buggy. The function is supported but the color stays light even in dark mode."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-function-max",
|
||||
"title":"max()",
|
||||
@@ -1027,6 +1091,38 @@
|
||||
"notes_by_num":{"1":"Buggy. Replaces `height` by `min-height`.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphenate-character",
|
||||
"title":"hyphenate-character",
|
||||
"description":"Sets the character (or string) used at the end of a line before a hyphenation break.",
|
||||
"url":"https://www.caniemail.com/features/css-hyphenate-character/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"hyphens, break",
|
||||
"last_test_date":"2024-06-19",
|
||||
"test_url":"https://www.caniemail.com/tests/css-hyphenate-character.html",
|
||||
"test_results_url":"https://testi.at/proj/vr3e1e5bikda08oxc2",
|
||||
"stats":{"apple-mail":{"macos":{"20":"n","21":"n","22":"n","23":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"},"mobile-webmail":{"2024-06":"n"}},"orange":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-06":"n"},"macos":{"2024-06":"n"},"outlook-com":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"yahoo":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"aol":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"n"},"android":{"2024-06":"n"}},"samsung-email":{"android":{"2024-06":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-06":"u"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"hey":{"desktop-webmail":{"2024-06":"u"}},"mail-ru":{"desktop-webmail":{"2024-06":"a #1"}},"fastmail":{"desktop-webmail":{"2024-06":"u"}},"laposte":{"desktop-webmail":{"2024-06":"u"}},"gmx":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"web-de":{"desktop-webmail":{"2024-06":"n"},"ios":{"2024-06":"u"},"android":{"2024-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-06":"u"},"android":{"2024-06":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Does not support encoded character values"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphenate-limit-chars",
|
||||
"title":"hyphenate-limit-chars",
|
||||
"description":"Specifies the minimum word length to allow hyphenation of words as well as the minimum number of characters before and after the hyphen.",
|
||||
"url":"https://www.caniemail.com/features/css-hyphenate-limit-chars/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-08-08",
|
||||
"test_url":"https://www.caniemail.com/tests/css-hyphenate-limit-chars.html",
|
||||
"test_results_url":"https://testi.at/proj/kgljcojhdyrfdv5s2",
|
||||
"stats":{"apple-mail":{"macos":{"2024-08":"n"},"ios":{"2024-08":"n"}},"gmail":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"},"mobile-webmail":{"2024-08":"n"}},"orange":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-08":"n"},"macos":{"2024-08":"n"},"outlook-com":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"yahoo":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"aol":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"n"},"android":{"2024-08":"n"}},"samsung-email":{"android":{"2024-08":"n"}},"sfr":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"thunderbird":{"macos":{"2024-08":"n"}},"protonmail":{"desktop-webmail":{"2024-08":"u"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"hey":{"desktop-webmail":{"2024-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-08":"y"}},"fastmail":{"desktop-webmail":{"2024-08":"u"}},"laposte":{"desktop-webmail":{"2024-08":"u"}},"gmx":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"web-de":{"desktop-webmail":{"2024-08":"n"},"ios":{"2024-08":"u"},"android":{"2024-08":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-08":"u"},"android":{"2024-08":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-hyphens",
|
||||
"title":"hyphens",
|
||||
@@ -1075,6 +1171,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-inset",
|
||||
"title":"inset",
|
||||
"description":"Shorthand that corresponds to the `top`, `right`, `bottom`, and/or `left` properties",
|
||||
"url":"https://www.caniemail.com/features/css-inset/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-29",
|
||||
"test_url":"https://www.caniemail.com/tests/css-inset.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpdia3k18jytjx8c2",
|
||||
"stats":{"apple-mail":{"macos":{"10.15":"n","11.7":"y"},"ios":{"14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2024-05":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"n"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"n"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-intrinsic-size",
|
||||
"title":"fit-content, min-content, max-content",
|
||||
@@ -1310,11 +1422,27 @@
|
||||
"last_test_date":"2019-10-01",
|
||||
"test_url":"https://www.caniemail.com/tests/css-margin.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/UmR6V6XenYY9bQiABuLGZRRrdP3fj2ZraiJjEyi4WKBho/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"yahoo":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-10":"a #1"},"ios":{"2019-10":"a #1"},"android":{"2019-10":"a #1"},"mobile-webmail":{"2020-02":"a #1"}},"orange":{"desktop-webmail":{"2019-10":"y","2021-03":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2 #3 #4","2010":"a #1 #2 #3 #4","2013":"a #1 #2 #3 #4","2016":"a #1 #2 #3 #4","2019":"a #1 #2 #3 #4"},"windows-mail":{"2019-10":"a #1 #2 #3"},"macos":{"2011":"y","2016":"y","16.80":"a #1"},"outlook-com":{"2019-10":"a #1","2023-12":"a #1"},"ios":{"2.51.1":"y","4.3.1":"a #1"},"android":{"2019-10":"a #1"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-10":"y"},"ios":{"2019-10":"y"},"android":{"2019-10":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"yahoo":{"desktop-webmail":{"2019-10":"y","2024-10":"a #1"},"ios":{"2019-10":"y","2024-10":"a #1"},"android":{"2019-10":"y","2024-10":"a #1"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"a #1"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Not supported on `<span>` and `<body>` elements.","3":"Buggy. `background-color` is included inside the `margin`.","4":"Partial. `auto` value is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-mask-image",
|
||||
"title":"mask-image",
|
||||
"description":"Sets the image that is used as mask layer for an element",
|
||||
"url":"https://www.caniemail.com/features/css-mask-image/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-11-27",
|
||||
"test_url":"https://www.caniemail.com/tests/css-mask-image.html",
|
||||
"test_results_url":"https://testi.at/proj/x9aotv8ysvn805531p",
|
||||
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"},"mobile-webmail":{"2024-11":"n"}},"orange":{"desktop-webmail":{"2024-11":"u"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n","2024":"n"},"windows-mail":{"2024-11":"n"},"macos":{"2024-11":"y"},"outlook-com":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"yahoo":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"aol":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"n"},"android":{"2024-11":"n"}},"samsung-email":{"android":{"2024-11":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-11":"u"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"hey":{"desktop-webmail":{"2024-11":"u"}},"mail-ru":{"desktop-webmail":{"2024-11":"y"}},"fastmail":{"desktop-webmail":{"2024-11":"u"}},"laposte":{"desktop-webmail":{"2024-11":"u"}},"gmx":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"web-de":{"desktop-webmail":{"2024-11":"n"},"ios":{"2024-11":"u"},"android":{"2024-11":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-11":"u"},"android":{"2024-11":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-max-block-size",
|
||||
"title":"max-block-size",
|
||||
@@ -1347,6 +1475,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-max-inline-size",
|
||||
"title":"max-inline-size",
|
||||
"description":"Defines the horizontal or vertical maximum size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-max-inline-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"max, inline, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-max-inline-size.html",
|
||||
"test_results_url":"https://testi.at/proj/8r8g0dn81y8jc72z09",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-max-width",
|
||||
"title":"max-width",
|
||||
@@ -1363,6 +1507,22 @@
|
||||
"notes_by_num":{"1":"Partial. Only works on `<table>` elements.","2":"Partial. Doesn't work on `<table>` elements, as per [CSS 2.1 specification](https://www.w3.org/TR/CSS2/visudet.html#min-max-widths)."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-min-block-size",
|
||||
"title":"min-block-size",
|
||||
"description":"Defines the minimum horizontal or vertical size of an element's block, depending on its writing mode",
|
||||
"url":"https://www.caniemail.com/features/css-min-block-size/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"min, block, size",
|
||||
"last_test_date":"2024-05-31",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-block-size.html",
|
||||
"test_results_url":"https://testi.at/proj/73yg05zgtpk3cez6ua5",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"},"mobile-webmail":{"2024-05":"n"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"n"},"outlook-com":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-min-height",
|
||||
"title":"min-height property",
|
||||
@@ -1390,7 +1550,7 @@
|
||||
"last_test_date":"2022-08-30",
|
||||
"test_url":"https://www.caniemail.com/tests/css-min-inline-size.html",
|
||||
"test_results_url":"https://testi.at/proj/6m0cx5puENPh8pLi9rpSPzJSB",
|
||||
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"u"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
|
||||
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2022-07":"y","16.80":"n"},"outlook-com":{"2022-07":"n","2024-01":"n"},"ios":{"2022-07":"y"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"samsung-email":{"android":{"2022-07":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
@@ -1507,6 +1667,22 @@
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-orphans",
|
||||
"title":"orphans",
|
||||
"description":"Sets the minimum number of lines in a block container split on an old page, region or column.",
|
||||
"url":"https://www.caniemail.com/features/css-orphans/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"columns",
|
||||
"last_test_date":"2024-06-13",
|
||||
"test_url":"https://www.caniemail.com/tests/css-widows.html",
|
||||
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `orphans` to work","2":"Buggy. `orphans` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `orphans` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-outline-offset",
|
||||
"title":"outline-offset",
|
||||
@@ -1558,17 +1734,17 @@
|
||||
{
|
||||
"slug":"css-overflow",
|
||||
"title":"overflow",
|
||||
"description":"",
|
||||
"description":"Sets the desired behavior when content does not fit in the element's padding box",
|
||||
"url":"https://www.caniemail.com/features/css-overflow/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2019-02-28",
|
||||
"last_test_date":"2024-10-02",
|
||||
"test_url":"https://www.caniemail.com/tests/css-box-model.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/pyPQFHSYLFrhbRShalju0B2fYNwUgLuyKTLx4MLqiw5mE/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"a #1"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-08":"n #2","2021-03":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"web-de":{"desktop-webmail":{"2022-06":"y"},"ios":{"2022-06":"y"},"android":{"2022-06":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"test_results_url":"https://testi.at/proj/p4rru3ez069p15p6ij",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"a #1","2024-10":"a #4"},"ios":{"12.1":"y","2024-10":"a #4"}},"gmail":{"desktop-webmail":{"2019-02":"y","2024-10":"a #4"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"y","2024-10":"a #4"},"mobile-webmail":{"2020-02":"y","2024-10":"a #4"}},"orange":{"desktop-webmail":{"2019-08":"n #2","2021-03":"y","2024-10":"a #3"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2019-02":"n"},"macos":{"2019-02":"y","16.80":"y","2024-10":"a #4"},"outlook-com":{"2019-02":"y","2024-01":"y","2024-10":"a #3"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"aol":{"desktop-webmail":{"2019-02":"y","2024-10":"a #4"},"ios":{"2019-02":"y","2024-10":"a #4"},"android":{"2019-02":"n"}},"samsung-email":{"android":{"5.0.10.2":"y","2024-10":"a #4"}},"sfr":{"desktop-webmail":{"2019-08":"y"},"ios":{"2019-08":"y"},"android":{"2019-08":"y"}},"thunderbird":{"macos":{"60.5":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y","2024-10":"a #3"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-06":"y","2024-10":"a #3"},"ios":{"2022-06":"y","2024-10":"a #3"},"android":{"2022-06":"y","2024-10":"a #3"}},"web-de":{"desktop-webmail":{"2022-06":"y","2024-10":"a #3"},"ios":{"2022-06":"y","2024-10":"a #3"},"android":{"2022-06":"y","2024-10":"a #3"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. Cannot scroll through to hidden content.","2":"Not supported. `overflow` is replaced by `java-script`."}
|
||||
"notes_by_num":{"1":"Buggy. Cannot scroll through to hidden content.","2":"Not supported. `overflow` is replaced by `java-script`.","3":"Partial. Support for `overflow-block` & `overflow-inline` depends on browser support.","4":"Partial. `overflow-block` & `overflow-inline` not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -2524,8 +2700,8 @@
|
||||
"tags":[],
|
||||
"keywords":"underline",
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text.html",
|
||||
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-decoration.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/3r2uYHjW7RohepVjh05qVkSQ9t7gJVJd6O5ABI8grFvqQ/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"a #2 #3","2010":"a #2 #3","2013":"a #2 #3","2016":"a #2 #3","2019":"a #2 #3"},"windows-mail":{"2019-02":"a #2 #3"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"n"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-07":"a #4"},"ios":{"2022-07":"a #5"},"android":{"2022-07":"y"}},"web-de":{"desktop-webmail":{"2022-07":"a #4"},"ios":{"2022-07":"a #5"},"android":{"2022-07":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-07":"y"},"android":{"2022-07":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. Not supported with multiple values.","3":"Partial. `overline` is not supported.","4":"Partial. Only supports the line property, not style, color or thickness.","5":"Partial. Only supports style, color or thickness when written with long hand selectors."}
|
||||
@@ -2579,6 +2755,22 @@
|
||||
"notes_by_num":{"1":"Partial. Negative values are not supported.","2":"Partial. Hard-coded negative values are not supported, but negative values as a result of the `calc()` function are supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-text-justify",
|
||||
"title":"text-justify",
|
||||
"description":"Sets what type of justification should be applied to text when `text-align: justify;` is set on an element.",
|
||||
"url":"https://www.caniemail.com/features/css-text-justify/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-04-17",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text-justify.html",
|
||||
"test_results_url":"https://testi.at/proj/z7b61px4fel2ivk9sb2",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"n #1"},"ios":{"2024-04":"n #1"}},"gmail":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"},"mobile-webmail":{"2024-04":"a #2"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"a #3","2016":"a #3","2019":"a #3","2021":"a #3"},"windows-mail":{"2024-04":"n #5"},"macos":{"2024-04":"n #1"},"outlook-com":{"2024-04":"a #2"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"yahoo":{"desktop-webmail":{"2024-04":"a #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"aol":{"desktop-webmail":{"2024-04":"n #2 #4"},"ios":{"2024-04":"n #1"},"android":{"2024-04":"n #1"}},"samsung-email":{"android":{"2024-04":"n #1"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"a #2"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"a #2"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n #1"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `text-justify` is stripped","2":"Partial. Depends on browser support","3":"Partial. `text-justify` is stripped except when the value is `inter-character`","4":"Partial. `text-justify` is stripped except when the value is `inter-word` or `distribute`","5":"Buggy. `text-justify` values `none`, `inter-word` and `distribute` are replaced with `inter-ideograph`"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-text-orientation",
|
||||
"title":"text-orientation",
|
||||
@@ -3059,6 +3251,22 @@
|
||||
"notes_by_num":{"1":"Buggy. `visibility:collapse` applied to a `<tr>` only hides content and does not \"remove\" it from layout.","2":"Partially supported. `visibility:collapse` is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-white-space-collapse",
|
||||
"title":"white-space-collapse",
|
||||
"description":"Controls how white space inside an element is collapsed.",
|
||||
"url":"https://www.caniemail.com/features/css-white-space-collapse/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"break, space, collapse, hide",
|
||||
"last_test_date":"2024-09-04",
|
||||
"test_url":"https://www.caniemail.com/tests/css-white-space-collapse.html",
|
||||
"test_results_url":"https://testi.at/proj/e6y4s3zytp5kty7kcg",
|
||||
"stats":{"apple-mail":{"macos":{"10":"n","11":"n","12":"n","13":"n","14":"y #1"},"ios":{"15":"y #1"}},"gmail":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"},"mobile-webmail":{"2024-09":"n"}},"orange":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-09":"n"},"macos":{"2024-09":"n"},"outlook-com":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"yahoo":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"aol":{"desktop-webmail":{"2024-09":"n"},"ios":{"2024-09":"n"},"android":{"2024-09":"n"}},"samsung-email":{"android":{"2024-09":"y #1"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"protonmail":{"desktop-webmail":{"2024-09":"u"},"ios":{"2024-09":"u"},"android":{"2024-09":"u"}},"hey":{"desktop-webmail":{"2024-09":"u"}},"mail-ru":{"desktop-webmail":{"2024-09":"y #1"}},"fastmail":{"desktop-webmail":{"2024-09":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. `preserve-spaces` value works only on Firefox."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-white-space",
|
||||
"title":"white-space",
|
||||
@@ -3075,6 +3283,22 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. `pre` value is not supported."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-widows",
|
||||
"title":"widows",
|
||||
"description":"Sets the minimum number of lines in a block container split on a new page, region or column.",
|
||||
"url":"https://www.caniemail.com/features/css-widows/",
|
||||
"category":"css",
|
||||
"tags":[],
|
||||
"keywords":"columns",
|
||||
"last_test_date":"2024-05-03",
|
||||
"test_url":"https://www.caniemail.com/tests/css-widows.html",
|
||||
"test_results_url":"https://testi.at/proj/6vd8udzx1b5l1vrnsr",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"n #4"},"ios":{"2024-05":"n"},"android":{"2024-05":"n #3"},"mobile-webmail":{"2024-05":"n #1 #2"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-05":"n"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"aol":{"desktop-webmail":{"2024-05":"n"},"ios":{"2024-05":"n"},"android":{"2024-05":"n"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y #4"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"n #1 #4"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Buggy. `columns` property is stripped which is required for `widows` to work","2":"Buggy. `widows` property value is replaced by `auto`","3":"Not supported, but Gmail's default styles on the email message container includes the `widows` property. These values are inherited by children elements","4":"Webmail rendering depends on browser support"}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"css-width",
|
||||
"title":"width property",
|
||||
@@ -3443,6 +3667,38 @@
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Partial. The element is present but is not interactive."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-cellpadding",
|
||||
"title":"cellpadding attribute",
|
||||
"description":"Represents the padding around the individual cells of the table",
|
||||
"url":"https://www.caniemail.com/features/html-cellpadding/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-01",
|
||||
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-cellspacing",
|
||||
"title":"cellspacing attribute",
|
||||
"description":"Represents the spacing around the individual `<th>` and `<td>` elements",
|
||||
"url":"https://www.caniemail.com/features/html-cellspacing/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-01",
|
||||
"test_url":"https://www.caniemail.com/tests/html-cellspacing.html",
|
||||
"test_results_url":"https://testi.at/proj/rlpatnzxf3eyce9oc3",
|
||||
"stats":{"apple-mail":{"macos":{"2024-05":"y"},"ios":{"2024-05":"y"}},"gmail":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"},"mobile-webmail":{"2024-05":"y"}},"orange":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-05":"y"},"macos":{"2024-05":"y"},"outlook-com":{"2024-05":"y","2024-01":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"yahoo":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"aol":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"y"},"android":{"2024-05":"y"}},"samsung-email":{"android":{"2024-05":"y"}},"sfr":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"thunderbird":{"macos":{"2024-05":"y"}},"protonmail":{"desktop-webmail":{"2024-05":"u"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"hey":{"desktop-webmail":{"2024-05":"u"}},"mail-ru":{"desktop-webmail":{"2024-05":"y"}},"fastmail":{"desktop-webmail":{"2024-05":"u"}},"laposte":{"desktop-webmail":{"2024-05":"u"}},"gmx":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"web-de":{"desktop-webmail":{"2024-05":"y"},"ios":{"2024-05":"u"},"android":{"2024-05":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-05":"u"},"android":{"2024-05":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-code",
|
||||
"title":"<code> element",
|
||||
@@ -3459,6 +3715,22 @@
|
||||
"notes_by_num":{"1":"Not supported. The tags are removed but the content is kept."}
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-comments",
|
||||
"title":"HTML comments",
|
||||
"description":"Adds explanatory notes to the code or to prevent the browser from interpreting specific parts of the style sheet",
|
||||
"url":"https://www.caniemail.com/features/html-comments/",
|
||||
"category":"html",
|
||||
"tags":[],
|
||||
"keywords":null,
|
||||
"last_test_date":"2024-05-1",
|
||||
"test_url":"https://www.caniemail.com/tests/css-comments.html",
|
||||
"test_results_url":"https://testi.at/proj/n4ayign05k6cozot6",
|
||||
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"2024-04":"y"}},"gmail":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"},"mobile-webmail":{"2024-04":"y"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"y","2016":"y","2019":"y","2021":"y"},"windows-mail":{"2024-04":"y"},"macos":{"2024-04":"y"},"outlook-com":{"2024-04":"y","2024-01":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"yahoo":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"aol":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"y"},"android":{"2024-04":"y"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"y"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"y"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":null
|
||||
},
|
||||
|
||||
{
|
||||
"slug":"html-del",
|
||||
"title":"<del> element",
|
||||
@@ -4157,7 +4429,7 @@
|
||||
"keywords":null,
|
||||
"last_test_date":"2019-02-28",
|
||||
"test_url":"https://www.caniemail.com/tests/css-text.html",
|
||||
"test_results_url":"https: //app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/DkqbHs69ek5UnK6uhZ7Uj0n5GVQNTP4Z1FvgXvnKyEoTM/list",
|
||||
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.1":"y"}},"gmail":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"a #1"},"android":{"2019-02":"a #1"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-01":"y","2021-03":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"outlook":{"windows":{"2007":"y","2010":"y","2013":"y","2016":"y","2019":"y"},"windows-mail":{"2019-02":"y"},"macos":{"2019-02":"y","16.80":"y"},"outlook-com":{"2019-02":"y","2024-01":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"yahoo":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"aol":{"desktop-webmail":{"2019-02":"y"},"ios":{"2019-02":"y"},"android":{"2019-02":"y"}},"samsung-email":{"android":{"5.0.10.2":"y"}},"sfr":{"desktop-webmail":{"2020-01":"y"},"ios":{"2020-01":"y"},"android":{"2020-01":"y"}},"thunderbird":{"macos":{"68.4":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"web-de":{"desktop-webmail":{"2022-11":"y"},"ios":{"2022-11":"y"},"android":{"2022-11":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"y"},"android":{"2022-11":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partial. Not supported with non Google accounts."}
|
||||
@@ -4510,7 +4782,7 @@
|
||||
"last_test_date":"2023-01-15",
|
||||
"test_url":"https://www.caniemail.com/tests/images.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
|
||||
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"y"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partially supported. Only works with non Google accounts."}
|
||||
},
|
||||
|
||||
71
internal/linkcheck/linkcheck_test.go
Normal file
71
internal/linkcheck/linkcheck_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
testHTML = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel=stylesheet href="http://remote-host/style.css"></link>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=ignored"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<p><a href="http://example.com">HTTP link</a></p>
|
||||
<p><a href="https://example.com">HTTPS link</a></p>
|
||||
<p><a href="HTTPS://EXAMPLE.COM">HTTPS link</a></p>
|
||||
<p><a href="http://localhost">Localhost link</a> (ignored)</p>
|
||||
<p><a href="https://localhost">Localhost link</a> (ignored)</p>
|
||||
<p><a href='https://127.0.0.1'>Single quotes link</a> (ignored)</p>
|
||||
<p><img src=https://example.com/image.jpg></p>
|
||||
<p href="http://invalid-link.com">This should be ignored</p>
|
||||
<p><a href="http://link with spaces">Link with spaces</a></p>
|
||||
<p><a href="http://example.com/?blaah=yes&test=true">URL-encoded characters</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
expectedHTMLLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "https://localhost", "https://127.0.0.1", "http://link with spaces", "http://example.com/?blaah=yes&test=true",
|
||||
"http://remote-host/style.css", // css
|
||||
"https://example.com/image.jpg", // images
|
||||
}
|
||||
|
||||
testTextLinks = `This is a line with http://example.com https://example.com
|
||||
HTTPS://EXAMPLE.COM
|
||||
[http://localhost]
|
||||
www.google.com < ignored
|
||||
|||http://example.com/?some=query-string|||
|
||||
`
|
||||
|
||||
expectedTextLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string",
|
||||
}
|
||||
)
|
||||
|
||||
func TestLinkDetection(t *testing.T) {
|
||||
|
||||
t.Log("Testing HTML link detection")
|
||||
|
||||
m := storage.Message{}
|
||||
|
||||
m.Text = testTextLinks
|
||||
m.HTML = testHTML
|
||||
|
||||
textLinks := extractTextLinks(&m)
|
||||
|
||||
if !reflect.DeepEqual(textLinks, expectedTextLinks) {
|
||||
t.Fatalf("Failed to detect text links correctly")
|
||||
}
|
||||
|
||||
htmlLinks := extractHTMLLinks(&m)
|
||||
|
||||
if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {
|
||||
t.Fatalf("Failed to detect HTML links correctly")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var linkRe = regexp.MustCompile(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\w@?^=%&\/~+#-])`)
|
||||
var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
func authUser(username, password string) bool {
|
||||
@@ -19,6 +20,11 @@ func authUser(username, password string) bool {
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
|
||||
if strings.HasPrefix(m, "-ERR ") {
|
||||
sub, _ := strings.CutPrefix(m, "-ERR ")
|
||||
websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub)
|
||||
}
|
||||
}
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
@@ -73,5 +79,6 @@ func getSafeArg(args []string, nr int) (string, error) {
|
||||
if nr < len(args) {
|
||||
return args[nr], nil
|
||||
}
|
||||
|
||||
return "", errors.New("-ERR out of range")
|
||||
}
|
||||
@@ -30,14 +30,17 @@ type Conn struct {
|
||||
|
||||
// Opt represents the client configuration.
|
||||
type Opt struct {
|
||||
// Host name
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
|
||||
// Default is 3 seconds.
|
||||
// Port number
|
||||
Port int `json:"port"`
|
||||
// DialTimeout default is 3 seconds.
|
||||
DialTimeout time.Duration `json:"dial_timeout"`
|
||||
Dialer Dialer `json:"-"`
|
||||
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
// Dialer
|
||||
Dialer Dialer `json:"-"`
|
||||
// TLSEnabled sets whether SLS is enabled
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
// TLSSkipVerify skips TLS verification (ie: self-signed)
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
}
|
||||
|
||||
@@ -49,16 +52,15 @@ type Dialer interface {
|
||||
// MessageID contains the ID and size of an individual message.
|
||||
type MessageID struct {
|
||||
// ID is the numerical index (non-unique) of the message.
|
||||
ID int
|
||||
ID int
|
||||
// Size in bytes
|
||||
Size int
|
||||
|
||||
// UID is only present if the response is to the UIDL command.
|
||||
UID string
|
||||
}
|
||||
|
||||
var (
|
||||
lineBreak = []byte("\r\n")
|
||||
|
||||
lineBreak = []byte("\r\n")
|
||||
respOK = []byte("+OK") // `+OK` without additional info
|
||||
respOKInfo = []byte("+OK ") // `+OK <info>`
|
||||
respErr = []byte("-ERR") // `-ERR` without additional info
|
||||
@@ -126,6 +128,7 @@ func (c *Conn) Send(b string) error {
|
||||
if _, err := c.w.WriteString(b + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.w.Flush()
|
||||
}
|
||||
|
||||
@@ -223,12 +226,14 @@ func (c *Conn) Auth(user, password string) error {
|
||||
// User sends the username to the server.
|
||||
func (c *Conn) User(s string) error {
|
||||
_, err := c.Cmd("USER", false, s)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass sends the password to the server.
|
||||
func (c *Conn) Pass(s string) error {
|
||||
_, err := c.Cmd("PASS", false, s)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
121
internal/smtpd/chaos/chaos.go
Normal file
121
internal/smtpd/chaos/chaos.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
|
||||
// See https://en.wikipedia.org/wiki/Chaos_engineering
|
||||
// See https://mailpit.axllent.org/docs/integration/chaos/
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
// Enabled is a flag to enable or disable support for chaos
|
||||
Enabled = false
|
||||
|
||||
// Config is the global Chaos configuration
|
||||
Config = Triggers{
|
||||
Sender: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Recipient: Trigger{ErrorCode: 451, Probability: 0},
|
||||
Authentication: Trigger{ErrorCode: 535, Probability: 0},
|
||||
}
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
Sender Trigger
|
||||
// Recipient trigger to fail on To, Cc, Bcc
|
||||
Recipient Trigger
|
||||
// Authentication trigger to fail while authenticating (auth must be configured)
|
||||
Authentication Trigger
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
// example: 451
|
||||
ErrorCode int
|
||||
|
||||
// Probability (chance) of triggering the error. The value must range from 0 to 100.
|
||||
// required: true
|
||||
// example: 5
|
||||
Probability int
|
||||
}
|
||||
|
||||
// SetFromStruct will set a whole map of chaos configurations (ie: API)
|
||||
func SetFromStruct(c Triggers) error {
|
||||
if c.Sender.ErrorCode == 0 {
|
||||
c.Sender.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Recipient.ErrorCode == 0 {
|
||||
c.Recipient.ErrorCode = 451 // default
|
||||
}
|
||||
|
||||
if c.Authentication.ErrorCode == 0 {
|
||||
c.Authentication.ErrorCode = 535 // default
|
||||
}
|
||||
|
||||
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set will set the chaos configuration for the given key (CLI & setMap())
|
||||
func Set(key string, errorCode int, probability int) error {
|
||||
Enabled = true
|
||||
if errorCode < 400 || errorCode > 599 {
|
||||
return fmt.Errorf("error code must be between 400 and 599")
|
||||
}
|
||||
|
||||
if probability > 100 || probability < 0 {
|
||||
return fmt.Errorf("probability must be between 0 and 100")
|
||||
}
|
||||
|
||||
key = strings.ToLower(key)
|
||||
|
||||
switch key {
|
||||
case "sender":
|
||||
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
|
||||
case "recipient", "recipients":
|
||||
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
|
||||
case "auth", "authentication":
|
||||
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
|
||||
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
|
||||
default:
|
||||
return fmt.Errorf("unknown key %s", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trigger will return whether the Chaos rule is triggered based on the configuration
|
||||
// and a randomly-generated percentage value.
|
||||
func (c Trigger) Trigger() (bool, int) {
|
||||
if !Enabled || c.Probability == 0 {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
|
||||
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
|
||||
// so value must be less than (not <=) to the probability to trigger
|
||||
return int(nBig.Int64()) < c.Probability, c.ErrorCode
|
||||
}
|
||||
111
internal/smtpd/forward.go
Normal file
111
internal/smtpd/forward.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Wrapper to forward messages if configured
|
||||
func autoForwardMessage(from string, data *[]byte) {
|
||||
if config.SMTPForwardConfig.Host == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := forward(from, *data); err != nil {
|
||||
logger.Log().Errorf("[forward] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[forward] message from %s to %s via %s:%d",
|
||||
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func forward(from string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
|
||||
}
|
||||
|
||||
defer c.Close()
|
||||
|
||||
if config.SMTPForwardConfig.STARTTLS {
|
||||
conf := &tls.Config{ServerName: config.SMTPForwardConfig.Host} // #nosec
|
||||
|
||||
conf.InsecureSkipVerify = config.SMTPForwardConfig.AllowInsecure
|
||||
|
||||
if err = c.StartTLS(conf); err != nil {
|
||||
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
auth := forwardAuthFromConfig()
|
||||
|
||||
if auth != nil {
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("error response to AUTH command: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPForwardConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
|
||||
to := strings.Split(config.SMTPForwardConfig.To, ",")
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Return the SMTP forwarding authentication based on config
|
||||
func forwardAuthFromConfig() smtp.Auth {
|
||||
var a smtp.Auth
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "plain" {
|
||||
a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "login" {
|
||||
a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
|
||||
}
|
||||
|
||||
if config.SMTPForwardConfig.Auth == "cram-md5" {
|
||||
a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
@@ -14,22 +14,26 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/mhale/smtpd"
|
||||
)
|
||||
|
||||
var (
|
||||
// DisableReverseDNS allows rDNS to be disabled
|
||||
DisableReverseDNS bool
|
||||
|
||||
warningResponse = regexp.MustCompile(`^4\d\d `)
|
||||
errorResponse = regexp.MustCompile(`^5\d\d `)
|
||||
)
|
||||
|
||||
// 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)
|
||||
return SaveToDatabase(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) {
|
||||
// SaveToDatabase will attempt to save a message to the database
|
||||
func SaveToDatabase(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
|
||||
@@ -38,7 +42,7 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
|
||||
stats.LogSMTPRejected()
|
||||
return "", err
|
||||
}
|
||||
@@ -83,6 +87,9 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
|
||||
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
|
||||
autoRelayMessage(from, to, &data)
|
||||
|
||||
// if enabled, this will forward a copy to preconfigured addresses
|
||||
autoForwardMessage(from, &data)
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
|
||||
@@ -189,42 +196,51 @@ func Listen() error {
|
||||
}
|
||||
}
|
||||
|
||||
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.MsgIDHandler, authHandler smtpd.AuthHandler) error {
|
||||
srv := &smtpd.Server{
|
||||
// Translate the smtpd verb from READ/WRITE
|
||||
func verbLogTranslator(verb string) string {
|
||||
if verb == "READ" {
|
||||
return "received"
|
||||
}
|
||||
|
||||
return "response"
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) error {
|
||||
|
||||
socketAddr, perm, isSocket := tools.UnixSocket(addr)
|
||||
|
||||
Debug = true // to enable Mailpit logging
|
||||
srv := &Server{
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
Appname: "Mailpit",
|
||||
AppName: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
LogRead: func(remoteIP, verb, line string) {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
},
|
||||
LogWrite: func(remoteIP, verb, line string) {
|
||||
if warningResponse.MatchString(line) {
|
||||
logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("warning", "smtpd", remoteIP, line)
|
||||
} else if errorResponse.MatchString(line) {
|
||||
logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("error", "smtpd", remoteIP, line)
|
||||
} else {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if config.Label != "" {
|
||||
srv.Appname = fmt.Sprintf("Mailpit (%s)", config.Label)
|
||||
srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label)
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
@@ -248,6 +264,39 @@ func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.A
|
||||
}
|
||||
}
|
||||
|
||||
if isSocket {
|
||||
srv.Addr = socketAddr
|
||||
srv.Protocol = "unix"
|
||||
srv.SocketPerm = perm
|
||||
|
||||
if err := tools.PrepareSocket(srv.Addr); err != nil {
|
||||
storage.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the Unix socket file on exit
|
||||
storage.AddTempFile(srv.Addr)
|
||||
|
||||
logger.Log().Infof("[smtpd] starting on %s", config.SMTPListen)
|
||||
} else {
|
||||
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 srv.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -5,13 +5,62 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// Wrapper to auto relay messages if configured
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
|
||||
filteredTo := []string{}
|
||||
for _, address := range to {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
|
||||
logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address)
|
||||
continue
|
||||
}
|
||||
|
||||
filteredTo = append(filteredTo, address)
|
||||
}
|
||||
to = filteredTo
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if config.SMTPRelayAll {
|
||||
if err := Relay(from, to, *data); err != nil {
|
||||
logger.Log().Errorf("[relay] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[relay] sent 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 := Relay(from, filtered, *data); err != nil {
|
||||
logger.Log().Errorf("[relay] error: %s", err.Error())
|
||||
} else {
|
||||
logger.Log().Debugf("[relay] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
|
||||
func Relay(from string, to []string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
@@ -39,6 +88,15 @@ func Send(from string, to []string, msg []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.SMTPRelayConfig.OverrideFrom != "" {
|
||||
msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error overriding From header: %s", err.Error())
|
||||
}
|
||||
|
||||
from = config.SMTPRelayConfig.OverrideFrom
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
}
|
||||
979
internal/smtpd/smtpd.go
Normal file
979
internal/smtpd/smtpd.go
Normal file
@@ -0,0 +1,979 @@
|
||||
// Package smtpd implements a basic SMTP server.
|
||||
//
|
||||
// This is a modified version of https://github.com/mhale/smtpd to
|
||||
// add support for unix sockets and Mailpit Chaos.
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
var (
|
||||
// Debug `true` enables verbose logging.
|
||||
Debug = false
|
||||
rcptToRE = regexp.MustCompile(`(?i)TO: ?<([^<>\v]+)>( |$)(.*)?`)
|
||||
mailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
|
||||
// extract mail size from 'MAIL FROM' parameter
|
||||
mailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`)
|
||||
)
|
||||
|
||||
// Handler function called upon successful receipt of an email.
|
||||
// Results in a "250 2.0.0 Ok: queued" response.
|
||||
type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) error
|
||||
|
||||
// MsgIDHandler function called upon successful receipt of an email. Returns a message ID.
|
||||
// Results in a "250 2.0.0 Ok: queued as <message-id>" response.
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte) (string, error)
|
||||
|
||||
// HandlerRcpt function called on RCPT. Return accept status.
|
||||
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
|
||||
|
||||
// AuthHandler function called when a login attempt is performed. Returns true if credentials are correct.
|
||||
type AuthHandler func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error)
|
||||
|
||||
// ErrServerClosed is the default message when a server closes a connection
|
||||
var ErrServerClosed = errors.New("Server has been closed")
|
||||
|
||||
// ListenAndServe listens on the TCP network address addr
|
||||
// and then calls Serve with handler to handle requests
|
||||
// on incoming connections.
|
||||
func ListenAndServe(addr string, handler Handler, appName string, hostname string) error {
|
||||
srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// ListenAndServeTLS listens on the TCP network address addr
|
||||
// and then calls Serve with handler to handle requests
|
||||
// on incoming connections. Connections may be upgraded to TLS if the client requests it.
|
||||
func ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler, appName string, hostname string) error {
|
||||
srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname}
|
||||
err := srv.ConfigureTLS(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
type maxSizeExceededError struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
func maxSizeExceeded(limit int) maxSizeExceededError {
|
||||
return maxSizeExceededError{limit}
|
||||
}
|
||||
|
||||
// Error uses the RFC 5321 response message in preference to RFC 1870.
|
||||
// RFC 3463 defines enhanced status code x.3.4 as "Message too big for system".
|
||||
func (err maxSizeExceededError) Error() string {
|
||||
return fmt.Sprintf("552 5.3.4 Requested mail action aborted: exceeded storage allocation (%d)", err.limit)
|
||||
}
|
||||
|
||||
// LogFunc is a function capable of logging the client-server communication.
|
||||
type LogFunc func(remoteIP, verb, line string)
|
||||
|
||||
// Server is an SMTP server.
|
||||
type Server struct {
|
||||
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
|
||||
AppName string
|
||||
AuthHandler AuthHandler
|
||||
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
|
||||
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
|
||||
DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname
|
||||
Handler Handler
|
||||
HandlerRcpt HandlerRcpt
|
||||
Hostname string
|
||||
LogRead LogFunc
|
||||
LogWrite LogFunc
|
||||
MaxSize int // Maximum message size allowed, in bytes
|
||||
MaxRecipients int // Maximum number of recipients, defaults to 100.
|
||||
MsgIDHandler MsgIDHandler
|
||||
Timeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
|
||||
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
|
||||
Protocol string // Default tcp, supports unix
|
||||
SocketPerm fs.FileMode // if using Unix socket, socket permissions
|
||||
|
||||
inShutdown int32 // server was closed or shutdown
|
||||
openSessions int32 // count of open sessions
|
||||
mu sync.Mutex
|
||||
shutdownChan chan struct{} // let the sessions know we are shutting down
|
||||
|
||||
XClientAllowed []string // List of XCLIENT allowed IP addresses
|
||||
}
|
||||
|
||||
// ConfigureTLS creates a TLS configuration from certificate and key files.
|
||||
func (srv *Server) ConfigureTLS(certFile string, keyFile string) error {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} // #nosec
|
||||
return nil
|
||||
}
|
||||
|
||||
// // ConfigureTLSWithPassphrase creates a TLS configuration from a certificate,
|
||||
// // an encrypted key file and the associated passphrase:
|
||||
// func (srv *Server) ConfigureTLSWithPassphrase(
|
||||
// certFile string,
|
||||
// keyFile string,
|
||||
// passphrase string,
|
||||
// ) error {
|
||||
// certPEMBlock, err := os.ReadFile(certFile)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// keyPEMBlock, err := os.ReadFile(keyFile)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// keyDERBlock, _ := pem.Decode(keyPEMBlock)
|
||||
// keyPEMDecrypted, err := x509.DecryptPEMBlock(keyDERBlock, []byte(passphrase))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// var pemBlock pem.Block
|
||||
// pemBlock.Type = keyDERBlock.Type
|
||||
// pemBlock.Bytes = keyPEMDecrypted
|
||||
// keyPEMBlock = pem.EncodeToMemory(&pemBlock)
|
||||
// cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// ListenAndServe listens on the either a TCP network address srv.Addr or
|
||||
// alternatively a Unix socket. and then calls Serve to handle requests on
|
||||
// incoming connections. If srv.Addr is blank, ":25" is used.
|
||||
func (srv *Server) ListenAndServe() error {
|
||||
if atomic.LoadInt32(&srv.inShutdown) != 0 {
|
||||
return ErrServerClosed
|
||||
}
|
||||
|
||||
if srv.Addr == "" {
|
||||
srv.Addr = ":25"
|
||||
}
|
||||
if srv.AppName == "" {
|
||||
srv.AppName = "smtpd"
|
||||
}
|
||||
if srv.Hostname == "" {
|
||||
srv.Hostname, _ = os.Hostname()
|
||||
}
|
||||
if srv.Timeout == 0 {
|
||||
srv.Timeout = 5 * time.Minute
|
||||
}
|
||||
if srv.Protocol == "" {
|
||||
srv.Protocol = "tcp"
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
|
||||
// If TLSListener is enabled, listen for TLS connections only.
|
||||
if srv.TLSConfig != nil && srv.TLSListener {
|
||||
ln, err = tls.Listen(srv.Protocol, srv.Addr, srv.TLSConfig)
|
||||
} else {
|
||||
ln, err = net.Listen(srv.Protocol, srv.Addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srv.Protocol == "unix" {
|
||||
// set permissions
|
||||
if err := os.Chmod(srv.Addr, srv.SocketPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
// Serve creates a new SMTP session after a network connection is established.
|
||||
func (srv *Server) Serve(ln net.Listener) error {
|
||||
if atomic.LoadInt32(&srv.inShutdown) != 0 {
|
||||
return ErrServerClosed
|
||||
}
|
||||
|
||||
defer ln.Close()
|
||||
|
||||
for {
|
||||
// if we are shutting down, don't accept new connections
|
||||
select {
|
||||
case <-srv.getShutdownChan():
|
||||
return ErrServerClosed
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
session := srv.newSession(conn)
|
||||
atomic.AddInt32(&srv.openSessions, 1)
|
||||
go session.serve()
|
||||
}
|
||||
}
|
||||
|
||||
type session struct {
|
||||
srv *Server
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
bw *bufio.Writer
|
||||
remoteIP string // Remote IP address
|
||||
remoteHost string // Remote hostname according to reverse DNS lookup
|
||||
remoteName string // Remote hostname as supplied with EHLO
|
||||
xClient string // Information string as supplied with XCLIENT
|
||||
xClientADDR string // Information string as supplied with XCLIENT ADDR
|
||||
xClientNAME string // Information string as supplied with XCLIENT NAME
|
||||
xClientTrust bool // Trust XCLIENT from current IP address
|
||||
tls bool
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
// Create new session from connection.
|
||||
func (srv *Server) newSession(conn net.Conn) (s *session) {
|
||||
s = &session{
|
||||
srv: srv,
|
||||
conn: conn,
|
||||
br: bufio.NewReader(conn),
|
||||
bw: bufio.NewWriter(conn),
|
||||
}
|
||||
|
||||
// Get remote end info for the Received header.
|
||||
s.remoteIP, _, _ = net.SplitHostPort(s.conn.RemoteAddr().String())
|
||||
if s.remoteIP == "" {
|
||||
s.remoteIP = "127.0.0.1"
|
||||
}
|
||||
if !s.srv.DisableReverseDNS {
|
||||
names, err := net.LookupAddr(s.remoteIP)
|
||||
if err == nil && len(names) > 0 {
|
||||
s.remoteHost = names[0]
|
||||
} else {
|
||||
s.remoteHost = "unknown"
|
||||
}
|
||||
} else {
|
||||
s.remoteHost = "unknown"
|
||||
}
|
||||
|
||||
// Set tls = true if TLS is already in use.
|
||||
_, s.tls = s.conn.(*tls.Conn)
|
||||
|
||||
for _, checkIP := range srv.XClientAllowed {
|
||||
if s.remoteIP == checkIP {
|
||||
s.xClientTrust = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (srv *Server) getShutdownChan() <-chan struct{} {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if srv.shutdownChan == nil {
|
||||
srv.shutdownChan = make(chan struct{})
|
||||
}
|
||||
|
||||
return srv.shutdownChan
|
||||
}
|
||||
|
||||
func (srv *Server) closeShutdownChan() {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if srv.shutdownChan == nil {
|
||||
srv.shutdownChan = make(chan struct{})
|
||||
}
|
||||
|
||||
select {
|
||||
case <-srv.shutdownChan:
|
||||
default:
|
||||
close(srv.shutdownChan)
|
||||
}
|
||||
}
|
||||
|
||||
// Close - closes the connection without waiting
|
||||
func (srv *Server) Close() error {
|
||||
atomic.StoreInt32(&srv.inShutdown, 1)
|
||||
srv.closeShutdownChan()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown - waits for current sessions to complete before closing
|
||||
func (srv *Server) Shutdown(ctx context.Context) error {
|
||||
atomic.StoreInt32(&srv.inShutdown, 1)
|
||||
srv.closeShutdownChan()
|
||||
|
||||
// wait for up to 30 seconds to allow the current sessions to
|
||||
// end
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
|
||||
for i := 0; i < 300; i++ {
|
||||
// wait for open sessions to close
|
||||
if atomic.LoadInt32(&srv.openSessions) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
timer.Reset(100 * time.Millisecond)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Function called to handle connection requests.
|
||||
func (s *session) serve() {
|
||||
defer atomic.AddInt32(&s.srv.openSessions, -1)
|
||||
defer s.conn.Close()
|
||||
|
||||
var from string
|
||||
var gotFrom bool
|
||||
var to []string
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// Send banner.
|
||||
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName)
|
||||
|
||||
loop:
|
||||
for {
|
||||
// Attempt to read a line from the socket.
|
||||
// On timeout, send a timeout message and return from serve().
|
||||
// On error, assume the client has gone away i.e. return from serve().
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
verb, args := s.parseLine(line)
|
||||
|
||||
switch verb {
|
||||
case "HELO":
|
||||
s.remoteName = args
|
||||
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "EHLO":
|
||||
s.remoteName = args
|
||||
s.writef("%s", s.makeEHLOResponse())
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "MAIL":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
|
||||
match := mailFromRE.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
} else {
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Sender.Trigger(); fail {
|
||||
s.writef("%d Chaos sender error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// Validate the SIZE parameter if one was sent.
|
||||
if len(match[2]) > 0 { // A parameter is present
|
||||
sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3])
|
||||
if sizeMatch == nil {
|
||||
// ignore other parameter
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
} else {
|
||||
// Enforce the maximum message size if one is set.
|
||||
size, err := strconv.Atoi(sizeMatch[2])
|
||||
if err != nil { // Bad SIZE parameter
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set
|
||||
err = maxSizeExceeded(s.srv.MaxSize)
|
||||
s.writef("%s", err.Error())
|
||||
} else { // SIZE ok
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
} else { // No parameters after FROM
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "RCPT":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
|
||||
break
|
||||
}
|
||||
|
||||
match := rcptToRE.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
} else {
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Recipient.Trigger(); fail {
|
||||
s.writef("%d Chaos recipient error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
if s.srv.MaxRecipients == 0 {
|
||||
s.srv.MaxRecipients = 100
|
||||
}
|
||||
|
||||
if len(to) == s.srv.MaxRecipients {
|
||||
s.writef("452 4.5.3 Too many recipients")
|
||||
} else {
|
||||
accept := true
|
||||
if s.srv.HandlerRcpt != nil {
|
||||
accept = s.srv.HandlerRcpt(s.conn.RemoteAddr(), from, match[1])
|
||||
}
|
||||
if accept {
|
||||
to = append(to, match[1])
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else {
|
||||
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "DATA":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom || len(to) == 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
|
||||
break
|
||||
}
|
||||
|
||||
s.writef("354 Start mail input; end with <CR><LF>.<CR><LF>")
|
||||
|
||||
// Attempt to read message body from the socket.
|
||||
// On timeout, send a timeout message and return from serve().
|
||||
// On net.Error, assume the client has gone away i.e. return from serve().
|
||||
// On other errors, allow the client to try again.
|
||||
data, err := s.readData()
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case net.Error:
|
||||
if err.(net.Error).Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
|
||||
}
|
||||
break loop
|
||||
case maxSizeExceededError:
|
||||
s.writef("%s", err.Error())
|
||||
continue
|
||||
default:
|
||||
s.writef("451 4.3.0 Requested action aborted: local error in processing")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Create Received header & write message body into buffer.
|
||||
buffer.Reset()
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
buffer.Write(data)
|
||||
|
||||
// Pass mail on to handler.
|
||||
if s.srv.Handler != nil {
|
||||
err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
break
|
||||
}
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
} else if s.srv.MsgIDHandler != nil {
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
s.writef("451 4.3.5 Unable to process mail")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if msgID != "" {
|
||||
s.writef("250 2.0.0 Ok: queued as %s", msgID)
|
||||
} else {
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
} else {
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
|
||||
// Reset for next mail.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "QUIT":
|
||||
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName)
|
||||
break loop
|
||||
case "RSET":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
s.writef("250 2.0.0 Ok")
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "NOOP":
|
||||
s.writef("250 2.0.0 Ok")
|
||||
case "XCLIENT":
|
||||
s.xClient = args
|
||||
if s.xClientTrust {
|
||||
xCArgs := strings.Split(args, " ")
|
||||
for _, xCArg := range xCArgs {
|
||||
xCParse := strings.Split(strings.TrimSpace(xCArg), "=")
|
||||
if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) {
|
||||
s.xClientADDR = xCParse[1]
|
||||
}
|
||||
if strings.ToUpper(xCParse[0]) == "NAME" && len(xCParse[1]) > 0 {
|
||||
if xCParse[1] != "[UNAVAILABLE]" {
|
||||
s.xClientNAME = xCParse[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.xClientADDR) > 7 {
|
||||
s.remoteIP = s.xClientADDR
|
||||
if len(s.xClientNAME) > 4 {
|
||||
s.remoteHost = s.xClientNAME
|
||||
} else {
|
||||
names, err := net.LookupAddr(s.remoteIP)
|
||||
if err == nil && len(names) > 0 {
|
||||
s.remoteHost = names[0]
|
||||
} else {
|
||||
s.remoteHost = "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.writef("250 2.0.0 Ok")
|
||||
case "HELP", "VRFY", "EXPN":
|
||||
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
case "STARTTLS":
|
||||
// Parameters are not allowed (RFC 3207 section 4).
|
||||
if args != "" {
|
||||
s.writef("501 5.5.2 Syntax error (no parameters allowed)")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where TLS is requested but not configured (and therefore not listed as a service extension).
|
||||
if s.srv.TLSConfig == nil {
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where STARTTLS is received when TLS is already in use.
|
||||
if s.tls {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (TLS already in use)")
|
||||
break
|
||||
}
|
||||
|
||||
s.writef("220 2.0.0 Ready to start TLS")
|
||||
|
||||
// Establish a TLS connection with the client.
|
||||
tlsConn := tls.Server(s.conn, s.srv.TLSConfig)
|
||||
err := tlsConn.Handshake()
|
||||
if err != nil {
|
||||
s.writef("403 4.7.0 TLS handshake failed")
|
||||
break
|
||||
}
|
||||
|
||||
// TLS handshake succeeded, switch to using the TLS connection.
|
||||
s.conn = tlsConn
|
||||
s.br = bufio.NewReader(s.conn)
|
||||
s.bw = bufio.NewWriter(s.conn)
|
||||
s.tls = true
|
||||
|
||||
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
|
||||
s.remoteName = ""
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "AUTH":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
// Handle case where AUTH is requested but not configured (and therefore not listed as a service extension).
|
||||
if s.srv.AuthHandler == nil {
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where AUTH is received when already authenticated.
|
||||
if s.authenticated {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (already authenticated for this session)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
|
||||
if gotFrom || len(to) > 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 requires a mechanism parameter.
|
||||
authType, authArgs := s.parseLine(args)
|
||||
if authType == "" {
|
||||
s.writef("501 5.5.4 Malformed AUTH input (argument required)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 requires rejecting unsupported authentication mechanisms with a 504 response.
|
||||
allowedAuth := s.authMechs()
|
||||
if allowed, found := allowedAuth[authType]; !found || !allowed {
|
||||
s.writef("504 5.5.4 Unrecognized authentication type")
|
||||
break
|
||||
}
|
||||
|
||||
// Mailpit Chaos
|
||||
if fail, code := chaos.Config.Authentication.Trigger(); fail {
|
||||
s.writef("%d Chaos authentication error", code)
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
|
||||
// when attempting to use an unsupported authentication type.
|
||||
// Many servers return 5.7.4 ("Security features not supported") instead.
|
||||
switch authType {
|
||||
case "PLAIN":
|
||||
s.authenticated, err = s.handleAuthPlain(authArgs)
|
||||
case "LOGIN":
|
||||
s.authenticated, err = s.handleAuthLogin(authArgs)
|
||||
case "CRAM-MD5":
|
||||
s.authenticated, err = s.handleAuthCramMD5()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName)
|
||||
break loop
|
||||
}
|
||||
|
||||
s.writef("%s", err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
if s.authenticated {
|
||||
s.writef("235 2.7.0 Authentication successful")
|
||||
} else {
|
||||
s.writef("535 5.7.8 Authentication credentials invalid")
|
||||
}
|
||||
default:
|
||||
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
|
||||
s.writef("500 5.5.2 Syntax error, command unrecognized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function for writing a complete line to the socket.
|
||||
func (s *session) writef(format string, args ...interface{}) {
|
||||
if s.srv.Timeout > 0 {
|
||||
_ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(s.bw, "%s\r\n", line)
|
||||
_ = s.bw.Flush()
|
||||
|
||||
if Debug {
|
||||
verb := "WROTE"
|
||||
if s.srv.LogWrite != nil {
|
||||
s.srv.LogWrite(s.remoteIP, verb, line)
|
||||
} else {
|
||||
log.Println(s.remoteIP, verb, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read a complete line from the socket.
|
||||
func (s *session) readLine() (string, error) {
|
||||
if s.srv.Timeout > 0 {
|
||||
_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line, err := s.br.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line = strings.TrimSpace(line) // Strip trailing \r\n
|
||||
|
||||
if Debug {
|
||||
verb := "READ"
|
||||
if s.srv.LogRead != nil {
|
||||
s.srv.LogRead(s.remoteIP, verb, line)
|
||||
} else {
|
||||
log.Println(s.remoteIP, verb, line)
|
||||
}
|
||||
}
|
||||
|
||||
return line, err
|
||||
}
|
||||
|
||||
// Parse a line read from the socket.
|
||||
func (s *session) parseLine(line string) (verb string, args string) {
|
||||
if idx := strings.Index(line, " "); idx != -1 {
|
||||
verb = strings.ToUpper(line[:idx])
|
||||
args = strings.TrimSpace(line[idx+1:])
|
||||
} else {
|
||||
verb = strings.ToUpper(line)
|
||||
args = ""
|
||||
}
|
||||
return verb, args
|
||||
}
|
||||
|
||||
// Read the message data following a DATA command.
|
||||
func (s *session) readData() ([]byte, error) {
|
||||
var data []byte
|
||||
for {
|
||||
if s.srv.Timeout > 0 {
|
||||
_ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line, err := s.br.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Handle end of data denoted by lone period (\r\n.\r\n)
|
||||
if bytes.Equal(line, []byte(".\r\n")) {
|
||||
break
|
||||
}
|
||||
// Remove leading period (RFC 5321 section 4.5.2)
|
||||
if line[0] == '.' {
|
||||
line = line[1:]
|
||||
}
|
||||
|
||||
// Enforce the maximum message size limit.
|
||||
if s.srv.MaxSize > 0 {
|
||||
if len(data)+len(line) > s.srv.MaxSize {
|
||||
_, _ = s.br.Discard(s.br.Buffered()) // Discard the buffer remnants.
|
||||
return nil, maxSizeExceeded(s.srv.MaxSize)
|
||||
}
|
||||
}
|
||||
|
||||
data = append(data, line...)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Create the Received header to comply with RFC 2821 section 3.8.2.
|
||||
// TODO: Work out what to do with multiple to addresses.
|
||||
func (s *session) makeHeaders(to []string) []byte {
|
||||
var buffer bytes.Buffer
|
||||
now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)")
|
||||
buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP))
|
||||
buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName))
|
||||
buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now))
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
// Determine allowed authentication mechanisms.
|
||||
// RFC 4954 specifies that plaintext authentication mechanisms such as LOGIN and PLAIN require a TLS connection.
|
||||
// This can be explicitly overridden e.g. setting s.srv.AuthMechs["LOGIN"] = true.
|
||||
func (s *session) authMechs() (mechs map[string]bool) {
|
||||
mechs = map[string]bool{"LOGIN": s.tls, "PLAIN": s.tls, "CRAM-MD5": true}
|
||||
|
||||
for mech := range mechs {
|
||||
allowed, found := s.srv.AuthMechs[mech]
|
||||
if found {
|
||||
mechs[mech] = allowed
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create the greeting string sent in response to an EHLO command.
|
||||
func (s *session) makeEHLOResponse() (response string) {
|
||||
response = fmt.Sprintf("250-%s greets %s\r\n", s.srv.Hostname, s.remoteName)
|
||||
|
||||
// RFC 1870 specifies that "SIZE 0" indicates no maximum size is in force.
|
||||
response += fmt.Sprintf("250-SIZE %d\r\n", s.srv.MaxSize)
|
||||
|
||||
// Only list STARTTLS if TLS is configured, but not currently in use.
|
||||
if s.srv.TLSConfig != nil && !s.tls {
|
||||
response += "250-STARTTLS\r\n"
|
||||
}
|
||||
|
||||
// Only list AUTH if an AuthHandler is configured and at least one mechanism is allowed.
|
||||
if s.srv.AuthHandler != nil {
|
||||
var mechs []string
|
||||
for mech, allowed := range s.authMechs() {
|
||||
if allowed {
|
||||
mechs = append(mechs, mech)
|
||||
}
|
||||
}
|
||||
if len(mechs) > 0 {
|
||||
response += "250-AUTH " + strings.Join(mechs, " ") + "\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
response += "250 ENHANCEDSTATUSCODES"
|
||||
return
|
||||
}
|
||||
|
||||
func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
if arg == "" {
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
username, err := base64.StdEncoding.DecodeString(arg)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
password, err := base64.StdEncoding.DecodeString(line)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
// If fast mode (AUTH PLAIN [arg]) is not used, prompt for credentials.
|
||||
if arg == "" {
|
||||
s.writef("334 ")
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(arg)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
parts := bytes.Split(data, []byte{0})
|
||||
if len(parts) != 3 {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">"
|
||||
|
||||
s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
|
||||
data, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if data == "*" {
|
||||
return false, errors.New("501 5.7.0 Authentication cancelled")
|
||||
}
|
||||
|
||||
buf, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
fields := strings.Split(string(buf), " ")
|
||||
if len(fields) < 2 {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "CRAM-MD5", []byte(fields[0]), []byte(fields[1]), []byte(shared))
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
1582
internal/smtpd/smtpd_test.go
Normal file
1582
internal/smtpd/smtpd_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ func Ping() error {
|
||||
}
|
||||
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
if strings.HasPrefix(service, "unix:") {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
@@ -112,7 +112,7 @@ func Check(msg []byte) (Result, error) {
|
||||
}
|
||||
} else {
|
||||
var client *spamc.Client
|
||||
if strings.HasPrefix("unix:", service) {
|
||||
if strings.HasPrefix(service, "unix:") {
|
||||
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
|
||||
} else {
|
||||
client = spamc.NewTCP(service, timeout)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// ProtoVersion is the protocol version
|
||||
@@ -81,6 +83,7 @@ func (c *Client) dial() (connection, error) {
|
||||
}
|
||||
return net.DialUnix("unix", nil, unixAddr)
|
||||
}
|
||||
|
||||
panic("Client.net must be either \"tcp\" or \"unix\"")
|
||||
}
|
||||
|
||||
@@ -107,26 +110,25 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(conn)
|
||||
_, err = bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n")
|
||||
if err != nil {
|
||||
if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n")
|
||||
if err != nil {
|
||||
|
||||
if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = bw.Write(email)
|
||||
if err != nil {
|
||||
|
||||
if _, err := bw.Write(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = bw.Flush()
|
||||
if err != nil {
|
||||
|
||||
if err := bw.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Client is supposed to close its writing side of the connection
|
||||
// after sending its request.
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -134,6 +136,7 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
lines []string
|
||||
br = bufio.NewReader(conn)
|
||||
)
|
||||
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err == io.EOF {
|
||||
@@ -171,11 +174,12 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// summary
|
||||
if spamMainRe.MatchString(row) {
|
||||
res := spamMainRe.FindStringSubmatch(row)
|
||||
if len(res) == 4 {
|
||||
if strings.ToLower(res[1]) == "true" || strings.ToLower(res[1]) == "yes" {
|
||||
if tools.InArray(res[1], []string{"true", "yes"}) {
|
||||
result.Spam = true
|
||||
} else {
|
||||
result.Spam = false
|
||||
@@ -197,8 +201,8 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
reachedRules = true
|
||||
continue
|
||||
}
|
||||
|
||||
// details
|
||||
// row = strings.Trim(row, " \t\r\n")
|
||||
if reachedRules && spamDetailsRe.MatchString(row) {
|
||||
res := spamDetailsRe.FindStringSubmatch(row)
|
||||
if len(res) == 5 {
|
||||
@@ -207,6 +211,7 @@ func (c *Client) parseOutput(output []string) Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -222,12 +227,11 @@ func (c *Client) Ping() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion))
|
||||
if err != nil {
|
||||
if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil {
|
||||
return err
|
||||
}
|
||||
err = conn.CloseWrite()
|
||||
if err != nil {
|
||||
|
||||
if err := conn.CloseWrite(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -241,5 +245,6 @@ func (c *Client) Ping() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,25 +61,32 @@ func pruneMessages() {
|
||||
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
total := CountTotal()
|
||||
if total > float64(config.MaxAgeInHours) {
|
||||
offset := config.MaxMessages
|
||||
if config.DemoMode {
|
||||
offset = 500
|
||||
}
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(offset)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
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
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +173,10 @@ func pruneMessages() {
|
||||
|
||||
logMessagesDeleted(len(ids))
|
||||
|
||||
if config.DemoMode {
|
||||
vacuumDb()
|
||||
}
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,25 +28,31 @@ import (
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
dbIsTemp bool
|
||||
sqlDriver string
|
||||
dbLastAction time.Time
|
||||
|
||||
// zstd compression encoder & decoder
|
||||
dbEncoder, _ = zstd.NewWriter(nil)
|
||||
dbDecoder, _ = zstd.NewReader(nil)
|
||||
|
||||
temporaryFiles = []string{}
|
||||
)
|
||||
|
||||
// InitDB will initialise the database
|
||||
func InitDB() error {
|
||||
var (
|
||||
dsn string
|
||||
err error
|
||||
)
|
||||
|
||||
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
|
||||
// delete the Unix socket file on exit
|
||||
AddTempFile(p)
|
||||
sqlDriver = "sqlite"
|
||||
dsn = p
|
||||
logger.Log().Debugf("[db] using temporary database: %s", p)
|
||||
@@ -74,8 +80,6 @@ func InitDB() error {
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
db, err = sql.Open(sqlDriver, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -154,12 +158,8 @@ func Close() {
|
||||
// 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 {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
}
|
||||
// delete all temporary files
|
||||
deleteTempFiles()
|
||||
}
|
||||
|
||||
// Ping the database connection and return an error if unsuccessful
|
||||
|
||||
@@ -27,8 +27,10 @@ import (
|
||||
// Store will save an email to the database tables.
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
// Parse message body with enmime
|
||||
env, err := enmime.ReadEnvelope(bytes.NewReader(*body))
|
||||
env, err := parser.ReadEnvelope(bytes.NewReader(*body))
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[message] %s", err.Error())
|
||||
return "", nil
|
||||
@@ -160,6 +162,8 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -243,7 +247,9 @@ func GetMessage(id string) (*Message, error) {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -394,7 +400,9 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -422,6 +430,21 @@ func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
|
||||
return nil, errors.New("attachment not found")
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// LatestID returns the latest message ID
|
||||
//
|
||||
// If a query argument is set in the request the function will return the
|
||||
|
||||
@@ -3,10 +3,12 @@ package storage
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing text email storage")
|
||||
@@ -38,113 +40,140 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMimeEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing mime email storage")
|
||||
setup(tenantID)
|
||||
|
||||
start := time.Now()
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email storage")
|
||||
} else {
|
||||
t.Logf("Testing mime email storage (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing mime email retrieval")
|
||||
} else {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, 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, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing message summary")
|
||||
} else {
|
||||
t.Logf("Testing message summary (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "Expected 1 result")
|
||||
|
||||
msg := summaries[0]
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
|
||||
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
|
||||
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing mime email retrieval")
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
|
||||
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
|
||||
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
|
||||
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
|
||||
|
||||
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, 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, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
}
|
||||
|
||||
func TestMessageSummary(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing message summary")
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, len(summaries), 1, "Expected 1 result")
|
||||
|
||||
msg := summaries[0]
|
||||
|
||||
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
|
||||
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
|
||||
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
|
||||
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
|
||||
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
|
||||
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
|
||||
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
|
||||
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
|
||||
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
|
||||
}
|
||||
|
||||
func BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
@@ -156,7 +185,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkImportMime(b *testing.B) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
|
||||
@@ -43,12 +43,18 @@ func ReindexAll() {
|
||||
logger.Log().Infof("reindexing %d messages", total)
|
||||
|
||||
type updateStruct struct {
|
||||
ID string
|
||||
// ID in database
|
||||
ID string
|
||||
// SearchText for searching
|
||||
SearchText string
|
||||
Snippet string
|
||||
Metadata string
|
||||
// Snippet for UI
|
||||
Snippet string
|
||||
// Metadata info
|
||||
Metadata string
|
||||
}
|
||||
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
for _, ids := range chunks {
|
||||
updates := []updateStruct{}
|
||||
|
||||
@@ -61,7 +67,7 @@ func ReindexAll() {
|
||||
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
env, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
@@ -135,5 +141,6 @@ func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
|
||||
for chunkSize < len(items) {
|
||||
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
|
||||
}
|
||||
|
||||
return append(chunks, items)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,15 @@ 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/*
|
||||
@@ -63,13 +58,6 @@ func dbApplySchemas() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -161,64 +149,4 @@ func dataMigrations() {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
6
internal/storage/schemas/1.21.2.sql
Normal file
6
internal/storage/schemas/1.21.2.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- DROP LEGACY MIGRATION TABLE
|
||||
DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }};
|
||||
|
||||
-- DROP LEGACY TAGS COLUMN
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_tags" }};
|
||||
ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags;
|
||||
22
internal/storage/schemas/1.21.8.sql
Normal file
22
internal/storage/schemas/1.21.8.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Rebuild message_tags to remove FOREIGN KEY REFERENCES
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }};
|
||||
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }};
|
||||
|
||||
ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
|
||||
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
ID TEXT NOT NULL,
|
||||
TagID INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID);
|
||||
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID);
|
||||
|
||||
INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old;
|
||||
|
||||
DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old;
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -187,17 +190,30 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pruneUnusedTags(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
}
|
||||
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
|
||||
|
||||
dbLastAction = time.Now()
|
||||
|
||||
// broadcast changes
|
||||
if len(ids) > 200 {
|
||||
websockets.Broadcast("prune", nil)
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
d := struct {
|
||||
ID string
|
||||
}{ID: id}
|
||||
websockets.Broadcast("delete", d)
|
||||
}
|
||||
}
|
||||
|
||||
addDeletedSize(int64(deleteSize))
|
||||
|
||||
logMessagesDeleted(total)
|
||||
@@ -354,6 +370,12 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
} else {
|
||||
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:inline" || lw == "has:inlines" {
|
||||
if exclude {
|
||||
q.Where("Inline = 0")
|
||||
} else {
|
||||
q.Where("Inline > 0")
|
||||
}
|
||||
} else if lw == "has:attachment" || lw == "has:attachments" {
|
||||
if exclude {
|
||||
q.Where("Attachments = 0")
|
||||
@@ -390,6 +412,22 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "larger:") && sizeToBytes(cleanString(w[7:])) > 0 {
|
||||
w = cleanString(w[7:])
|
||||
size := sizeToBytes(w)
|
||||
if exclude {
|
||||
q.Where("Size < ?", size)
|
||||
} else {
|
||||
q.Where("Size > ?", size)
|
||||
}
|
||||
} else if strings.HasPrefix(lw, "smaller:") && sizeToBytes(cleanString(w[8:])) > 0 {
|
||||
w = cleanString(w[8:])
|
||||
size := sizeToBytes(w)
|
||||
if exclude {
|
||||
q.Where("Size > ?", size)
|
||||
} else {
|
||||
q.Where("Size < ?", size)
|
||||
}
|
||||
} else {
|
||||
// search text
|
||||
if exclude {
|
||||
@@ -402,3 +440,39 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m.
|
||||
//
|
||||
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
|
||||
// M, m, Mb, MB and mb are treated as Megabytes.
|
||||
func sizeToBytes(v string) int64 {
|
||||
v = strings.ToLower(v)
|
||||
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
|
||||
|
||||
m := re.FindAllStringSubmatch(v, -1)
|
||||
if len(m) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
val := fmt.Sprintf("%s%s", m[0][1], m[0][2])
|
||||
unit := m[0][3]
|
||||
|
||||
i, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if unit == "" {
|
||||
return int64(i)
|
||||
}
|
||||
|
||||
if unit == "k" || unit == "kb" {
|
||||
return int64(i * 1024)
|
||||
}
|
||||
|
||||
if unit == "m" || unit == "mb" {
|
||||
return int64(i * 1024 * 1024)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -6,133 +6,154 @@ import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing search")
|
||||
for i := 0; i < testRuns; i++ {
|
||||
msg := enmime.Builder().
|
||||
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
|
||||
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("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))
|
||||
setup(tenantID)
|
||||
|
||||
env, err := msg.Build()
|
||||
if tenantID == "" {
|
||||
t.Log("Testing search")
|
||||
} else {
|
||||
t.Logf("Testing search (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
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("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 {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
uniqueSearches := []string{
|
||||
fmt.Sprintf("from-%d@example.com", i),
|
||||
fmt.Sprintf("from:from-%d@example.com", i),
|
||||
fmt.Sprintf("to-%d@example.com", i),
|
||||
fmt.Sprintf("to:to-%d@example.com", i),
|
||||
fmt.Sprintf("to2-%d@example.com", i),
|
||||
fmt.Sprintf("to:to2-%d@example.com", i),
|
||||
fmt.Sprintf("cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc2-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc2-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
|
||||
fmt.Sprintf("\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
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")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), testRuns, "search results expected")
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
if err := env.Encode(buf); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
Close()
|
||||
}
|
||||
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
uniqueSearches := []string{
|
||||
fmt.Sprintf("from-%d@example.com", i),
|
||||
fmt.Sprintf("from:from-%d@example.com", i),
|
||||
fmt.Sprintf("to-%d@example.com", i),
|
||||
fmt.Sprintf("to:to-%d@example.com", i),
|
||||
fmt.Sprintf("to2-%d@example.com", i),
|
||||
fmt.Sprintf("to:to2-%d@example.com", i),
|
||||
fmt.Sprintf("cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc-%d@example.com", i),
|
||||
fmt.Sprintf("cc2-%d@example.com", i),
|
||||
fmt.Sprintf("cc:cc2-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to-%d@example.com", i),
|
||||
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
|
||||
fmt.Sprintf("\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("subject:\"Subject line %d end\"", i),
|
||||
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
|
||||
}
|
||||
searchIdx := rand.Intn(len(uniqueSearches))
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
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")
|
||||
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
|
||||
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
|
||||
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), testRuns, "search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete100(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
setup(tenantID)
|
||||
|
||||
if tenantID == "" {
|
||||
t.Log("Testing search delete of 100 messages")
|
||||
} else {
|
||||
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 100, "100 search results expected")
|
||||
|
||||
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, total, 0, "0 search results expected")
|
||||
}
|
||||
|
||||
func TestSearchDelete1100(t *testing.T) {
|
||||
setup()
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
@@ -180,3 +201,25 @@ func TestEscPercentChar(t *testing.T) {
|
||||
assertEqual(t, res, expected, "no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeToBytes(t *testing.T) {
|
||||
tests := map[string]int64{}
|
||||
tests["1m"] = 1048576
|
||||
tests["1mb"] = 1048576
|
||||
tests["1 M"] = 1048576
|
||||
tests["1 MB"] = 1048576
|
||||
tests["1k"] = 1024
|
||||
tests["1kb"] = 1024
|
||||
tests["1 K"] = 1024
|
||||
tests["1 kB"] = 1024
|
||||
tests["1.5M"] = 1572864
|
||||
tests["1234567890"] = 1234567890
|
||||
tests["invalid"] = 0
|
||||
tests["1.2.3"] = 0
|
||||
tests["1.2.3M"] = 0
|
||||
|
||||
for search, expected := range tests {
|
||||
res := sizeToBytes(search)
|
||||
assertEqual(t, res, expected, "size does not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package storage
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// Message data excluding physical attachments
|
||||
@@ -114,21 +112,6 @@ type DBMailSummary struct {
|
||||
ReplyTo []*mail.Address
|
||||
}
|
||||
|
||||
// AttachmentSummary returns a summary of the attachment without any binary data
|
||||
func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
o := Attachment{}
|
||||
o.PartID = a.PartID
|
||||
o.FileName = a.FileName
|
||||
if o.FileName == "" {
|
||||
o.FileName = a.ContentID
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
// including validation of the link structure
|
||||
type ListUnsubscribe struct {
|
||||
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
|
||||
// TagFilter struct
|
||||
type TagFilter struct {
|
||||
// Match is the user-defined match
|
||||
Match string
|
||||
SQL *sqlf.Stmt
|
||||
Tags []string
|
||||
// SQL represents the SQL equivalent of Match
|
||||
SQL *sqlf.Stmt
|
||||
// Tags to add on match
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var tagFilters = []TagFilter{}
|
||||
|
||||
@@ -108,6 +108,7 @@ func addMessageTag(id, name string) (string, error) {
|
||||
Set("ID", id).
|
||||
Set("TagID", tagID).
|
||||
ExecAndClose(context.TODO(), db)
|
||||
|
||||
return foundName.String, err
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ func addMessageTag(id, name string) (string, error) {
|
||||
func deleteMessageTag(id, name string) error {
|
||||
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).
|
||||
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN `+tenant("tags")+` ON TagID=`+tenant("tags.ID")+` WHERE Name = ?)`, name).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,127 +4,140 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
setup()
|
||||
defer Close()
|
||||
|
||||
t.Log("Testing tags")
|
||||
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
|
||||
tenantID = config.DBTenantID(tenantID)
|
||||
|
||||
ids := []string{}
|
||||
setup(tenantID)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if tenantID == "" {
|
||||
t.Log("Testing tags")
|
||||
} else {
|
||||
t.Logf("Testing tags (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
}
|
||||
}
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
// pad number with 0 to ensure they are returned alphabetically
|
||||
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
|
||||
}
|
||||
if _, err := SetMessageTags(id, newTags); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
returnedTags := getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
message, err := GetMessage(ids[i])
|
||||
// remove first tag
|
||||
if err := deleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
|
||||
|
||||
// apply the same tag twice
|
||||
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// apply tag with invalid characters
|
||||
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
|
||||
t.Fatal("Message tags do not match")
|
||||
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()
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
newTags := []string{}
|
||||
for i := 0; i < 20; i++ {
|
||||
// pad number with 0 to ensure they are returned alphabetically
|
||||
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
|
||||
}
|
||||
if _, err := SetMessageTags(id, newTags); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags := getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
// remove first tag
|
||||
if err := deleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
|
||||
|
||||
// remove all tags
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
|
||||
|
||||
// apply the same tag twice
|
||||
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// apply tag with invalid characters
|
||||
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Check deleted message tags also prune the tags database
|
||||
allTags := GetAllTags()
|
||||
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
|
||||
|
||||
if err := DeleteAllMessages(); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ var (
|
||||
testRuns = 100
|
||||
)
|
||||
|
||||
func setup() {
|
||||
func setup(tenantID string) {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
config.TenantID = config.DBTenantID(tenantID)
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,20 @@ var (
|
||||
StatsDeleted float64
|
||||
)
|
||||
|
||||
// AddTempFile adds a file to the slice of files to delete on exit
|
||||
func AddTempFile(s string) {
|
||||
temporaryFiles = append(temporaryFiles, s)
|
||||
}
|
||||
|
||||
// DeleteTempFiles will delete files added via AddTempFiles
|
||||
func deleteTempFiles() {
|
||||
for _, f := range temporaryFiles {
|
||||
if err := os.Remove(f); err == nil {
|
||||
logger.Log().Debugf("removed temporary file: %s", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a header field as a []*mail.Address, or "null" is not found/empty
|
||||
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
|
||||
data, err := env.AddressList(key)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -48,7 +49,7 @@ func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] removed %s header", hdr)
|
||||
logger.Log().Debugf("[relay] removed %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(""), 1)
|
||||
}
|
||||
}
|
||||
@@ -90,10 +91,70 @@ func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
logger.Log().Debugf("[release] replaced %s header", hdr)
|
||||
logger.Log().Debugf("[relay] replaced %s header", hdr)
|
||||
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// OverrideFromHeader scans a message for the From header and replaces it with a different email address.
|
||||
func OverrideFromHeader(msg []byte, address string) ([]byte, error) {
|
||||
reader := bytes.NewReader(msg)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.Header.Get("From") != "" {
|
||||
reBlank := regexp.MustCompile(`^\s+`)
|
||||
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta("From:"))
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(msg))
|
||||
found := false
|
||||
hdr := []byte("")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !found && reHdr.Match(line) {
|
||||
// add the first line starting with <header>:
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
found = true
|
||||
} else if found && reBlank.Match(line) {
|
||||
// add any following lines starting with a whitespace (tab or space)
|
||||
hdr = append(hdr, line...)
|
||||
hdr = append(hdr, []byte("\r\n")...)
|
||||
} else if found {
|
||||
// stop scanning, we have the full <header>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(hdr) > 0 {
|
||||
originalFrom := strings.TrimRight(string(hdr[5:]), "\r\n")
|
||||
|
||||
from, err := mail.ParseAddress(originalFrom)
|
||||
if err != nil {
|
||||
// error parsing the from address, so just replace the whole line
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+address+"\r\n"), 1)
|
||||
} else {
|
||||
originalFrom = from.Address
|
||||
// replace the from email, but keep the original name
|
||||
from.Address = address
|
||||
msg = bytes.Replace(msg, hdr, []byte("From: "+from.String()+"\r\n"), 1)
|
||||
}
|
||||
|
||||
// insert the original From header as X-Original-From
|
||||
msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...)
|
||||
|
||||
logger.Log().Debugf("[relay] Replaced From email address with %s", address)
|
||||
}
|
||||
} else {
|
||||
// no From header, so add one
|
||||
msg = append([]byte("From: "+address+"\r\n"), msg...)
|
||||
logger.Log().Debugf("[relay] Added From email: %s", address)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
@@ -17,3 +17,34 @@ func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
|
||||
return "", fmt.Errorf("%s not found", key)
|
||||
}
|
||||
|
||||
// SetHTMLAttributeVal sets an attribute on a node.
|
||||
func SetHTMLAttributeVal(n *html.Node, key, val string) {
|
||||
for i := range n.Attr {
|
||||
a := &n.Attr[i]
|
||||
if a.Key == key {
|
||||
a.Val = val
|
||||
return
|
||||
}
|
||||
}
|
||||
n.Attr = append(n.Attr, html.Attribute{
|
||||
Key: key,
|
||||
Val: val,
|
||||
})
|
||||
}
|
||||
|
||||
// WalkHTML traverses the entire HTML tree and calls fn on each node.
|
||||
func WalkHTML(n *html.Node, fn func(*html.Node)) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fn(n)
|
||||
|
||||
// Each node has a pointer to its first child and next sibling. To traverse
|
||||
// all children of a node, we need to start from its first child and then
|
||||
// traverse the next sibling until nil.
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
WalkHTML(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func CreateSnippet(text, html string) string {
|
||||
return data
|
||||
}
|
||||
|
||||
return data[0:limit] + "..."
|
||||
return truncate(data, limit) + "..."
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
@@ -37,8 +37,33 @@ func CreateSnippet(text, html string) string {
|
||||
return text
|
||||
}
|
||||
|
||||
return text[0:limit] + "..."
|
||||
return truncate(text, limit) + "..."
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Truncate a string allowing for multi-byte encoding.
|
||||
// Shamelessly borrowed from Tailscale.
|
||||
// See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go
|
||||
func truncate(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
|
||||
// Back up until we find the beginning of a UTF-8 encoding.
|
||||
for n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte
|
||||
n--
|
||||
}
|
||||
|
||||
// If we're at the beginning of a multi-byte encoding, back up one more to
|
||||
// skip it. It's possible the value was already complete, but it's simpler
|
||||
// if we only have to check in one direction.
|
||||
//
|
||||
// Otherwise, we have a single-byte code (0x00... or 0x01...).
|
||||
if n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding
|
||||
n--
|
||||
}
|
||||
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
49
internal/tools/unixsocket.go
Normal file
49
internal/tools/unixsocket.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// UnixSocket returns a path and a FileMode if the address is in
|
||||
// the format of unix:<path>:<permission>
|
||||
func UnixSocket(address string) (string, fs.FileMode, bool) {
|
||||
re := regexp.MustCompile(`^unix:(.*):(\d\d\d\d?)$`)
|
||||
|
||||
var f fs.FileMode
|
||||
|
||||
if !re.MatchString(address) {
|
||||
return "", f, false
|
||||
}
|
||||
|
||||
m := re.FindAllStringSubmatch(address, 1)
|
||||
|
||||
modeVal, err := strconv.ParseUint(m[0][2], 8, 32)
|
||||
|
||||
if err != nil {
|
||||
return "", f, false
|
||||
}
|
||||
|
||||
return path.Clean(m[0][1]), fs.FileMode(modeVal), true
|
||||
}
|
||||
|
||||
// PrepareSocket returns an error if an active socket file already exists
|
||||
func PrepareSocket(address string) error {
|
||||
address = path.Clean(address)
|
||||
if _, err := os.Stat(address); os.IsNotExist(err) {
|
||||
// does not exist, OK
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := net.Dial("unix", address); err == nil {
|
||||
// socket is listening
|
||||
return fmt.Errorf("socket already in use: %s", address)
|
||||
}
|
||||
|
||||
return os.Remove(address)
|
||||
}
|
||||
@@ -11,14 +11,14 @@ 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 {
|
||||
if strings.EqualFold(v, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func extract(filePath string, directory string) error {
|
||||
}
|
||||
|
||||
// set file permissions, timestamps & uid/gid
|
||||
_ = os.Chmod(filename, os.FileMode(header.Mode))
|
||||
_ = os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
|
||||
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
}
|
||||
|
||||
@@ -71,5 +71,6 @@ func Unzip(src string, dest string) ([]string, error) {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
// AllowPrereleases defines whether pre-releases may be included
|
||||
AllowPrereleases = false
|
||||
|
||||
// temporary directory
|
||||
tempDir string
|
||||
)
|
||||
|
||||
|
||||
5
main.go
5
main.go
@@ -16,10 +16,11 @@ func main() {
|
||||
}
|
||||
|
||||
// running directly
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) ||
|
||||
!strings.Contains(filepath.Base(os.Args[0]), "sendmail") {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked
|
||||
// symlinked as "*sendmail*"
|
||||
sendmail.Run()
|
||||
}
|
||||
}
|
||||
|
||||
2825
package-lock.json
generated
2825
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"build": "MINIFY=true node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
"update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.23.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/reiver/go-telnet"
|
||||
"github.com/mneis/go-telnet"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ var (
|
||||
SMTPAddr = "localhost:1025"
|
||||
// FromAddr email address
|
||||
FromAddr string
|
||||
|
||||
// UseB - used to set from `-bs`
|
||||
UseB bool
|
||||
// UseS - used to set from `-bs`
|
||||
@@ -83,6 +82,9 @@ func Run() {
|
||||
flag.BoolP("long-i", "i", false, "Ignored")
|
||||
flag.BoolP("long-o", "o", false, "Ignored")
|
||||
flag.BoolP("long-t", "t", false, "Ignored")
|
||||
flag.StringP("from-name", "F", "", "Ignored")
|
||||
flag.StringP("bits", "B", "", "Ignored")
|
||||
flag.StringP("errors", "e", "", "Ignored")
|
||||
|
||||
// set the default help
|
||||
flag.Usage = func() {
|
||||
@@ -114,14 +116,23 @@ func Run() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
socketAddr, isSocket := socketAddress(SMTPAddr)
|
||||
|
||||
// handles `sendmail -bs`
|
||||
// telnet directly to SMTP
|
||||
if UseB && UseS {
|
||||
var caller telnet.Caller = telnet.StandardCaller
|
||||
|
||||
// telnet directly to SMTP
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
if isSocket {
|
||||
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
@@ -168,8 +179,7 @@ func Run() {
|
||||
os.Exit(11)
|
||||
}
|
||||
|
||||
err = smtp.SendMail(SMTPAddr, nil, from.Address, addresses, body)
|
||||
if err != nil {
|
||||
if err := Send(SMTPAddr, from.Address, addresses, body); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
@@ -191,5 +201,22 @@ Flags:
|
||||
-i Ignored
|
||||
-o Ignored
|
||||
-v Ignored
|
||||
-F string Ignored
|
||||
-B string Ignored
|
||||
-e string Ignored
|
||||
`, config.Version, strings.Join(args, " "), FromAddr)
|
||||
}
|
||||
|
||||
// SocketAddress returns a path and a FileMode if the address is in
|
||||
// the format of unix:<path>
|
||||
func socketAddress(address string) (string, bool) {
|
||||
re := regexp.MustCompile(`^unix:(.*)$`)
|
||||
|
||||
if !re.MatchString(address) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
m := re.FindAllStringSubmatch(address, 1)
|
||||
|
||||
return path.Clean(m[0][1]), true
|
||||
}
|
||||
|
||||
71
sendmail/cmd/smtp.go
Normal file
71
sendmail/cmd/smtp.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package cmd is a wrapper library to send mail
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
// Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets.
|
||||
// Unix sockets must be set as unix:/path/to/socket
|
||||
// It does not support authentication.
|
||||
func Send(addr string, from string, to []string, msg []byte) error {
|
||||
socketPath, isSocket := socketAddress(addr)
|
||||
|
||||
fromAddress, err := mail.ParseAddress(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid from address: %s", from)
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return fmt.Errorf("no To addresses specified")
|
||||
}
|
||||
|
||||
if !isSocket {
|
||||
return smtp.SendMail(addr, nil, fromAddress.Address, to, msg)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to %s", addr)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the sender
|
||||
if err := client.Mail(fromAddress.Address); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error sending mail")
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
// Set the recipient
|
||||
for _, a := range to {
|
||||
if err := client.Rcpt(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = wc.Write(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = wc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,685 +2,16 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/messages messages GetMessages
|
||||
//
|
||||
// # List messages
|
||||
//
|
||||
// Returns messages from the mailbox ordered from newest to oldest.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: Limit results
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 50
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// default: ErrorResponse
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
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
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Search returns the latest messages as JSON
|
||||
func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/search messages MessagesSummary
|
||||
//
|
||||
// # Search messages
|
||||
//
|
||||
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// description: Search query
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: start
|
||||
// in: query
|
||||
// description: Pagination offset
|
||||
// required: false
|
||||
// type: integer
|
||||
// default: 0
|
||||
// + name: limit
|
||||
// in: query
|
||||
// description: Limit results
|
||||
// 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
|
||||
// default: ErrorResponse
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
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
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/search messages DeleteSearch
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: query
|
||||
// in: query
|
||||
// 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
|
||||
// default: ErrorResponse
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// GetMessage (method: GET) returns the Message as JSON
|
||||
func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID} message Message
|
||||
//
|
||||
// # Get message summary
|
||||
//
|
||||
// Returns the summary of a message, marking the message as read.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: Message
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(msg); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
|
||||
//
|
||||
// # Get message attachment
|
||||
//
|
||||
// This will return the attachment part using the appropriate Content-Type.
|
||||
//
|
||||
// Produces:
|
||||
// - application/*
|
||||
// - image/*
|
||||
// - text/*
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: BinaryResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// GetHeaders (method: GET) returns the message headers as JSON
|
||||
func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/headers message Headers
|
||||
//
|
||||
// # Get message headers
|
||||
//
|
||||
// Returns the message headers as an array.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message headers.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessageHeaders
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
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
|
||||
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/raw message Raw
|
||||
//
|
||||
// # Get message source
|
||||
//
|
||||
// Returns the full email source as plain text.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message source.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
|
||||
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/messages messages DeleteMessages
|
||||
//
|
||||
// # Delete messages
|
||||
//
|
||||
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var data struct {
|
||||
IDs []string
|
||||
}
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil || len(data.IDs) == 0 {
|
||||
if err := storage.DeleteAllMessages(); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := storage.DeleteMessages(data.IDs); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
// If no IDs are provided then all messages are updated.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatus
|
||||
//
|
||||
// # Set read status
|
||||
//
|
||||
// If no IDs are provided then all messages are updated.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
|
||||
if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := storage.MarkAllUnread()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkUnread(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
|
||||
//
|
||||
// # HTML check
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
e := bytes.NewReader(raw)
|
||||
|
||||
msg, err := enmime.ReadEnvelope(e)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
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
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: LinkCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
f := r.URL.Query().Get("follow")
|
||||
followRedirects := f == "true" || f == "1"
|
||||
|
||||
summary, err := linkcheck.RunTests(msg, followRedirects)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
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
|
||||
//
|
||||
// Returns the SpamAssassin summary (if enabled) of the message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: SpamAssassinResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := spamassassin.Check(msg)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
@@ -6,8 +6,42 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
)
|
||||
|
||||
// Application information
|
||||
// swagger:response AppInfoResponse
|
||||
type appInfoResponse struct {
|
||||
// Application information
|
||||
//
|
||||
// in: body
|
||||
Body stats.AppInformation
|
||||
}
|
||||
|
||||
// AppInfo returns some basic details about the running app, and latest release.
|
||||
func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
//
|
||||
// # Get application information
|
||||
//
|
||||
// Returns basic runtime information, message totals and latest release version.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: AppInfoResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Response includes global web UI settings
|
||||
//
|
||||
// swagger:model WebUIConfiguration
|
||||
@@ -26,6 +60,8 @@ type webUIConfiguration struct {
|
||||
AllowedRecipients string
|
||||
// Block relaying to these recipients (regex)
|
||||
BlockedRecipients string
|
||||
// Overrides the "From" address for all relayed messages
|
||||
OverrideFrom string
|
||||
// DEPRECATED 2024/03/12
|
||||
// swagger:ignore
|
||||
RecipientAllowlist string
|
||||
@@ -34,10 +70,22 @@ type webUIConfiguration struct {
|
||||
// Whether SpamAssassin is enabled
|
||||
SpamAssassin bool
|
||||
|
||||
// Whether Chaos support is enabled at runtime
|
||||
ChaosEnabled bool
|
||||
|
||||
// Whether messages with duplicate IDs are ignored
|
||||
DuplicatesIgnored bool
|
||||
}
|
||||
|
||||
// Web UI configuration response
|
||||
// swagger:response WebUIConfigurationResponse
|
||||
type webUIConfigurationResponse struct {
|
||||
// Web UI configuration settings
|
||||
//
|
||||
// in: body
|
||||
Body webUIConfiguration
|
||||
}
|
||||
|
||||
// WebUIConfig returns configuration settings for the web UI.
|
||||
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/webui application WebUIConfiguration
|
||||
@@ -48,13 +96,14 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// Intended for web UI only!
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: WebUIConfigurationResponse
|
||||
// default: ErrorResponse
|
||||
// 200: WebUIConfigurationResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
conf := webUIConfiguration{}
|
||||
|
||||
conf.Label = config.Label
|
||||
@@ -64,11 +113,13 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.ChaosEnabled = chaos.Enabled
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
112
server/apiv1/chaos.go
Normal file
112
server/apiv1/chaos.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
// ChaosTriggers is the Chaos configuration
|
||||
//
|
||||
// swagger:model Triggers
|
||||
type ChaosTriggers chaos.Triggers
|
||||
|
||||
// Response for the Chaos triggers configuration
|
||||
// swagger:response ChaosResponse
|
||||
type chaosResponse struct {
|
||||
// The current Chaos triggers
|
||||
//
|
||||
// in: body
|
||||
Body ChaosTriggers
|
||||
}
|
||||
|
||||
// GetChaos returns the current Chaos triggers
|
||||
func GetChaos(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/chaos testing getChaos
|
||||
//
|
||||
// # Get Chaos triggers
|
||||
//
|
||||
// Returns the current Chaos triggers configuration.
|
||||
// This API route will return an error if Chaos is not enabled at runtime.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ChaosResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
if !chaos.Enabled {
|
||||
httpError(w, "Chaos is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
conf := chaos.Config
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters setChaosParams
|
||||
type setChaosParams struct {
|
||||
// in: body
|
||||
Body ChaosTriggers
|
||||
}
|
||||
|
||||
// SetChaos sets the Chaos configuration.
|
||||
func SetChaos(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/chaos testing setChaosParams
|
||||
//
|
||||
// # Set Chaos triggers
|
||||
//
|
||||
// Set the Chaos triggers configuration and return the updated values.
|
||||
// This API route will return an error if Chaos is not enabled at runtime.
|
||||
//
|
||||
// If any triggers are omitted from the request, then those are reset to their
|
||||
// default values with a 0% probability (ie: disabled).
|
||||
// Setting a blank `{}` will reset all triggers to their default values.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ChaosResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
if !chaos.Enabled {
|
||||
httpError(w, "Chaos is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
data := chaos.Triggers{}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := chaos.SetFromStruct(data); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
conf := chaos.Config
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
)
|
||||
|
||||
// AppInfo returns some basic details about the running app, and latest release.
|
||||
func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
//
|
||||
// # Get application information
|
||||
//
|
||||
// Returns basic runtime information, message totals and latest release version.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: InfoResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
257
server/apiv1/message.go
Normal file
257
server/apiv1/message.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// swagger:parameters GetMessageParams
|
||||
type getMessageParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// GetMessage (method: GET) returns the Message as JSON
|
||||
func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID} message GetMessageParams
|
||||
//
|
||||
// # Get message summary
|
||||
//
|
||||
// Returns the summary of a message, marking the message as read.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: Message
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(msg); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters GetHeadersParams
|
||||
type getHeadersParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// Message headers
|
||||
// swagger:model MessageHeadersResponse
|
||||
type messageHeaders map[string][]string
|
||||
|
||||
// GetHeaders (method: GET) returns the message headers as JSON
|
||||
func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/headers message GetHeadersParams
|
||||
//
|
||||
// # Get message headers
|
||||
//
|
||||
// Returns the message headers as an array. Note that header keys are returned alphabetically.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message headers.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessageHeadersResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
m, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters AttachmentParams
|
||||
type attachmentParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// Attachment part ID
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
PartID string
|
||||
}
|
||||
|
||||
// DownloadAttachment (method: GET) returns the attachment data
|
||||
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message AttachmentParams
|
||||
//
|
||||
// # Get message attachment
|
||||
//
|
||||
// This will return the attachment part using the appropriate Content-Type.
|
||||
//
|
||||
// The ID can be set to `latest` to reference the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/*
|
||||
// - image/*
|
||||
// - text/*
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: BinaryResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
partID := vars["partID"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a, err := storage.GetAttachmentPart(id, partID)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
fileName := a.FileName
|
||||
if fileName == "" {
|
||||
fileName = a.ContentID
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// swagger:parameters DownloadRawParams
|
||||
type downloadRawParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// DownloadRaw (method: GET) returns the full email source as plain text
|
||||
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/raw message DownloadRawParams
|
||||
//
|
||||
// # Get message source
|
||||
//
|
||||
// Returns the full email source as plain text.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message source.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
dl := r.FormValue("dl")
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
382
server/apiv1/messages.go
Normal file
382
server/apiv1/messages.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
// swagger:parameters GetMessagesParams
|
||||
type getMessagesParams struct {
|
||||
// Pagination offset
|
||||
//
|
||||
// in: query
|
||||
// name: start
|
||||
// required: false
|
||||
// default: 0
|
||||
// type: integer
|
||||
Start int `json:"start"`
|
||||
|
||||
// Limit number of results
|
||||
//
|
||||
// in: query
|
||||
// name: limit
|
||||
// required: false
|
||||
// default: 50
|
||||
// type: integer
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// Summary of messages
|
||||
// swagger:response MessagesSummaryResponse
|
||||
type messagesSummaryResponse struct {
|
||||
// The messages summary
|
||||
// in: body
|
||||
Body MessagesSummary
|
||||
}
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
// Total number of messages in mailbox
|
||||
Total float64 `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
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 float64 `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
// All current tags
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// Messages summary
|
||||
// in: body
|
||||
Messages []storage.MessageSummary `json:"messages"`
|
||||
}
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/messages messages GetMessagesParams
|
||||
//
|
||||
// # List messages
|
||||
//
|
||||
// Returns messages from the mailbox ordered from newest to oldest.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
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
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters SetReadStatusParams
|
||||
type setReadStatusParams struct {
|
||||
// in: body
|
||||
Body struct {
|
||||
// Read status
|
||||
//
|
||||
// required: false
|
||||
// default: false
|
||||
// example: true
|
||||
Read bool
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
|
||||
// If no IDs are provided then all messages are updated.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
|
||||
//
|
||||
// # Set read status
|
||||
//
|
||||
// If no IDs are provided then all messages are updated.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
var data struct {
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ids := data.IDs
|
||||
|
||||
if len(ids) == 0 {
|
||||
if data.Read {
|
||||
err := storage.MarkAllRead()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := storage.MarkAllUnread()
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data.Read {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkRead(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if err := storage.MarkUnread(id); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// swagger:parameters DeleteMessagesParams
|
||||
type deleteMessagesParams struct {
|
||||
// Delete request
|
||||
// in: body
|
||||
Body struct {
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
|
||||
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/messages messages DeleteMessagesParams
|
||||
//
|
||||
// # Delete messages
|
||||
//
|
||||
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var data struct {
|
||||
IDs []string
|
||||
}
|
||||
err := decoder.Decode(&data)
|
||||
if err != nil || len(data.IDs) == 0 {
|
||||
if err := storage.DeleteAllMessages(); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := storage.DeleteMessages(data.IDs); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// swagger:parameters SearchParams
|
||||
type searchParams struct {
|
||||
// Search query
|
||||
//
|
||||
// in: query
|
||||
// required: true
|
||||
// type: string
|
||||
Query string `json:"query"`
|
||||
|
||||
// Pagination offset
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type integer
|
||||
Start string `json:"start"`
|
||||
|
||||
// Limit results
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type integer
|
||||
Limit string `json:"limit"`
|
||||
|
||||
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type string
|
||||
TZ string `json:"tz"`
|
||||
}
|
||||
|
||||
// Search returns the latest messages as JSON
|
||||
func Search(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/search messages SearchParams
|
||||
//
|
||||
// # Search messages
|
||||
//
|
||||
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stats := storage.StatsGet()
|
||||
|
||||
var res MessagesSummary
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
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
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters DeleteSearchParams
|
||||
type deleteSearchParams struct {
|
||||
// Search query
|
||||
//
|
||||
// in: query
|
||||
// required: true
|
||||
// type: string
|
||||
Query string `json:"query"`
|
||||
|
||||
// [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland").
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type string
|
||||
TZ string `json:"tz"`
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages matching a search
|
||||
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/search messages DeleteSearchParams
|
||||
//
|
||||
// # Delete messages by search
|
||||
//
|
||||
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search == "" {
|
||||
httpError(w, "Error: no search query")
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
234
server/apiv1/other.go
Normal file
234
server/apiv1/other.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// swagger:parameters HTMLCheckParams
|
||||
type htmlCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// HTMLCheckResponse summary response
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check other HTMLCheckParams
|
||||
//
|
||||
// # HTML check
|
||||
//
|
||||
// Returns the summary of the message HTML checker.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
e := bytes.NewReader(raw)
|
||||
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
msg, err := parser.ReadEnvelope(e)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(checks); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters LinkCheckParams
|
||||
type linkCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// Follow redirects
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: false
|
||||
Follow string `json:"follow"`
|
||||
}
|
||||
|
||||
// LinkCheckResponse summary response
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
// 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 LinkCheckParams
|
||||
//
|
||||
// # Link check
|
||||
//
|
||||
// Returns the summary of the message Link checker.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: LinkCheckResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
f := r.URL.Query().Get("follow")
|
||||
followRedirects := f == "true" || f == "1"
|
||||
|
||||
summary, err := linkcheck.RunTests(msg, followRedirects)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters SpamAssassinCheckParams
|
||||
type spamAssassinCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// SpamAssassinResponse summary response
|
||||
type SpamAssassinResponse = spamassassin.Result
|
||||
|
||||
// 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 SpamAssassinCheckParams
|
||||
//
|
||||
// # SpamAssassin check
|
||||
//
|
||||
// Returns the SpamAssassin summary (if enabled) of the message.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: SpamAssassinResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := spamassassin.Check(msg)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -10,32 +10,59 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// swagger:parameters ReleaseMessageParams
|
||||
type releaseMessageParams struct {
|
||||
// Message database ID
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// in: body
|
||||
Body struct {
|
||||
// Array of email addresses to relay the message to
|
||||
//
|
||||
// required: true
|
||||
// example: ["user1@example.com", "user2@example.com"]
|
||||
To []string
|
||||
}
|
||||
}
|
||||
|
||||
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
|
||||
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
|
||||
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessageParams
|
||||
//
|
||||
// # Release message
|
||||
//
|
||||
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
|
||||
//
|
||||
// The ID can be set to `latest` to reference the latest message.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
@@ -49,7 +76,9 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := releaseMessageRequestBody{}
|
||||
var data struct {
|
||||
To []string
|
||||
}
|
||||
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
httpError(w, err.Error())
|
||||
@@ -156,7 +185,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := smtpd.Send(from, data.To, msg); err != nil {
|
||||
if err := smtpd.Relay(from, data.To, msg); err != nil {
|
||||
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
|
||||
httpError(w, "SMTP error: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// swagger:parameters SendMessage
|
||||
// swagger:parameters SendMessageParams
|
||||
type sendMessageParams struct {
|
||||
// in: body
|
||||
Body *SendRequest
|
||||
@@ -79,23 +80,33 @@ type SendRequest struct {
|
||||
Subject string
|
||||
|
||||
// Message body (text)
|
||||
// example: This is the text body
|
||||
// example: Mailpit is awesome!
|
||||
Text string
|
||||
|
||||
// Message body (HTML)
|
||||
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
|
||||
// example: <div style="text-align:center"><p style="font-family: arial; font-size: 24px;">Mailpit is <b>awesome</b>!</p><p><img src="cid:mailpit-logo" /></p></div>
|
||||
HTML string
|
||||
|
||||
// Attachments
|
||||
Attachments []struct {
|
||||
// Base64-encoded string of the file content
|
||||
// required: true
|
||||
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
|
||||
// example: iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==
|
||||
Content string
|
||||
// Filename
|
||||
// required: true
|
||||
// example: AttachedFile.txt
|
||||
// example: mailpit.png
|
||||
Filename string
|
||||
// Optional Content Type for the the attachment.
|
||||
// If this field is not set (or empty) then the content type is automatically detected.
|
||||
// required: false
|
||||
// example: image/png
|
||||
ContentType string
|
||||
// Optional Content-ID (`cid`) for attachment.
|
||||
// If this field is set then the file is attached inline.
|
||||
// required: false
|
||||
// example: mailpit-logo
|
||||
ContentID string
|
||||
}
|
||||
|
||||
// Mailpit tags
|
||||
@@ -107,13 +118,6 @@ type SendRequest struct {
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// SendMessageConfirmation struct
|
||||
type SendMessageConfirmation struct {
|
||||
// Database ID
|
||||
// example: iAfZVVe2UQFNSG5BAjgYwa
|
||||
ID string
|
||||
}
|
||||
|
||||
// JSONErrorMessage struct
|
||||
type JSONErrorMessage struct {
|
||||
// Error message
|
||||
@@ -121,25 +125,46 @@ type JSONErrorMessage struct {
|
||||
Error 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
|
||||
}
|
||||
|
||||
// SendMessageConfirmation struct
|
||||
type SendMessageConfirmation struct {
|
||||
// Database ID
|
||||
// example: iAfZVVe2UQfNSG5BAjgYwa
|
||||
ID 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
|
||||
// swagger:route POST /api/v1/send message SendMessageParams
|
||||
//
|
||||
// # Send a message
|
||||
//
|
||||
// Send a message via the HTTP API.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: sendMessageResponse
|
||||
// default: jsonErrorResponse
|
||||
// 200: sendMessageResponse
|
||||
// 400: jsonErrorResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpJSONError(w, "this functionality has been disabled for demonstration purposes")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -254,9 +279,15 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
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)
|
||||
contentType := http.DetectContentType(b)
|
||||
if a.ContentType != "" {
|
||||
contentType = a.ContentType
|
||||
}
|
||||
if a.ContentID != "" {
|
||||
msg = msg.AddInline(b, contentType, a.Filename, a.ContentID)
|
||||
} else {
|
||||
msg = msg.AddAttachment(b, contentType, a.Filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,5 +302,5 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
return "", fmt.Errorf("error building message: %s", err.Error())
|
||||
}
|
||||
|
||||
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
|
||||
return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
)
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
type MessagesSummary struct {
|
||||
// Total number of messages in mailbox
|
||||
Total float64 `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
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 float64 `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
|
||||
// All current tags
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// Messages summary
|
||||
// in: body
|
||||
Messages []storage.MessageSummary `json:"messages"`
|
||||
}
|
||||
|
||||
// The following structs & aliases are provided for easy import
|
||||
// and understanding of the JSON structure.
|
||||
|
||||
@@ -45,12 +15,3 @@ type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
|
||||
// HTMLCheckResponse summary
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
||||
// LinkCheckResponse summary
|
||||
type LinkCheckResponse = linkcheck.Response
|
||||
|
||||
// SpamAssassinResponse summary
|
||||
type SpamAssassinResponse = spamassassin.Result
|
||||
|
||||
@@ -1,177 +1,8 @@
|
||||
package apiv1
|
||||
|
||||
import "github.com/axllent/mailpit/internal/stats"
|
||||
|
||||
// These structs are for the purpose of defining swagger HTTP parameters & responses
|
||||
|
||||
// Application information
|
||||
// swagger:response InfoResponse
|
||||
type infoResponse struct {
|
||||
// Application information
|
||||
//
|
||||
// in: body
|
||||
Body stats.AppInformation
|
||||
}
|
||||
|
||||
// Web UI configuration
|
||||
// swagger:response WebUIConfigurationResponse
|
||||
type webUIConfigurationResponse struct {
|
||||
// Web UI configuration settings
|
||||
//
|
||||
// in: body
|
||||
Body webUIConfiguration
|
||||
}
|
||||
|
||||
// Message summary
|
||||
// swagger:response MessagesSummaryResponse
|
||||
type messagesSummaryResponse struct {
|
||||
// The message summary
|
||||
// in: body
|
||||
Body MessagesSummary
|
||||
}
|
||||
|
||||
// Message headers
|
||||
// swagger:model MessageHeaders
|
||||
type messageHeaders map[string][]string
|
||||
|
||||
// swagger:parameters DeleteMessages
|
||||
type deleteMessagesParams struct {
|
||||
// in: body
|
||||
Body *deleteMessagesRequestBody
|
||||
}
|
||||
|
||||
// Delete request
|
||||
// swagger:model DeleteRequest
|
||||
type deleteMessagesRequestBody struct {
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters SetReadStatus
|
||||
type setReadStatusParams struct {
|
||||
// in: body
|
||||
Body *setReadStatusRequestBody
|
||||
}
|
||||
|
||||
// Set read status request
|
||||
// swagger:model setReadStatusRequestBody
|
||||
type setReadStatusRequestBody struct {
|
||||
// Read status
|
||||
//
|
||||
// required: false
|
||||
// default: false
|
||||
// example: true
|
||||
Read bool
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters SetTags
|
||||
type setTagsParams struct {
|
||||
// in: body
|
||||
Body *setTagsRequestBody
|
||||
}
|
||||
|
||||
// Set tags request
|
||||
// swagger:model setTagsRequestBody
|
||||
type setTagsRequestBody struct {
|
||||
// Array of tag names to set
|
||||
//
|
||||
// required: true
|
||||
// example: ["Tag 1", "Tag 2"]
|
||||
Tags []string
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: true
|
||||
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// swagger:parameters RenameTag
|
||||
type renameTagParams struct {
|
||||
// in: body
|
||||
Body *renameTagRequestBody
|
||||
}
|
||||
|
||||
// Rename tag request
|
||||
// swagger:model renameTagRequestBody
|
||||
type renameTagRequestBody struct {
|
||||
// New name
|
||||
//
|
||||
// required: true
|
||||
// example: New name
|
||||
Name string
|
||||
}
|
||||
|
||||
// swagger:parameters ReleaseMessage
|
||||
type releaseMessageParams struct {
|
||||
// Message database ID
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// in: body
|
||||
Body *releaseMessageRequestBody
|
||||
}
|
||||
|
||||
// Release request
|
||||
// swagger:model releaseMessageRequestBody
|
||||
type releaseMessageRequestBody struct {
|
||||
// Array of email addresses to relay the message to
|
||||
// required: true
|
||||
// example: ["user1@example.com", "user2@example.com"]
|
||||
To []string
|
||||
}
|
||||
|
||||
// swagger:parameters HTMLCheck
|
||||
type htmlCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// swagger:parameters LinkCheck
|
||||
type linkCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// Follow redirects
|
||||
//
|
||||
// in: query
|
||||
// description: Follow redirects
|
||||
// required: false
|
||||
// default: false
|
||||
Follow string `json:"follow"`
|
||||
}
|
||||
|
||||
// swagger:parameters SpamAssassinCheck
|
||||
type spamAssassinCheckParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// description: Message database ID or "latest"
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// Binary data response inherits the attachment's content type.
|
||||
// Binary data response which inherits the attachment's content type.
|
||||
// swagger:response BinaryResponse
|
||||
type binaryResponse string
|
||||
|
||||
@@ -183,11 +14,15 @@ type textResponse string
|
||||
// swagger:response HTMLResponse
|
||||
type htmlResponse string
|
||||
|
||||
// HTTP error response will return with a >= 400 response code
|
||||
// Server error will return with a 400 status code
|
||||
// with the error message in the body
|
||||
// swagger:response ErrorResponse
|
||||
// example: invalid request
|
||||
type errorResponse string
|
||||
|
||||
// Not found error will return a 404 status code
|
||||
// swagger:response NotFoundResponse
|
||||
type notFoundResponse string
|
||||
|
||||
// Plain text "ok" response
|
||||
// swagger:response OKResponse
|
||||
type okResponse string
|
||||
@@ -196,15 +31,6 @@ type okResponse string
|
||||
// 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 {
|
||||
|
||||
@@ -18,13 +18,13 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) {
|
||||
// Returns a JSON array of all unique message tags.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: ArrayResponse
|
||||
// default: ErrorResponse
|
||||
// 200: ArrayResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
|
||||
@@ -32,25 +32,43 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters SetTagsParams
|
||||
type setTagsParams struct {
|
||||
// in: body
|
||||
Body struct {
|
||||
// Array of tag names to set
|
||||
//
|
||||
// required: true
|
||||
// example: ["Tag 1", "Tag 2"]
|
||||
Tags []string
|
||||
|
||||
// Array of message database IDs
|
||||
//
|
||||
// required: true
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessageTags (method: PUT) will set the tags for all provided IDs
|
||||
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/tags tags SetTags
|
||||
// swagger:route PUT /api/v1/tags tags SetTagsParams
|
||||
//
|
||||
// # Set message tags
|
||||
//
|
||||
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -80,29 +98,41 @@ func SetMessageTags(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// swagger:parameters RenameTagParams
|
||||
type renameTagParams struct {
|
||||
// The url-encoded tag name to rename
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
// type: string
|
||||
Tag string
|
||||
|
||||
// in: body
|
||||
Body struct {
|
||||
// New name
|
||||
//
|
||||
// required: true
|
||||
// example: New name
|
||||
Name string
|
||||
}
|
||||
}
|
||||
|
||||
// RenameTag (method: PUT) used to rename a tag
|
||||
func RenameTag(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/tags/{tag} tags RenameTag
|
||||
// swagger:route PUT /api/v1/tags/{Tag} tags RenameTagParams
|
||||
//
|
||||
// # Rename a tag
|
||||
//
|
||||
// Renames a tag.
|
||||
// Renames an existing tag.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: tag
|
||||
// in: path
|
||||
// description: The url-encoded tag name to rename
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
@@ -131,29 +161,31 @@ func RenameTag(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// swagger:parameters DeleteTagParams
|
||||
type deleteTagParams struct {
|
||||
// The url-encoded tag name to delete
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
Tag string
|
||||
}
|
||||
|
||||
// DeleteTag (method: DELETE) used to delete a tag
|
||||
func DeleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag
|
||||
// swagger:route DELETE /api/v1/tags/{Tag} tags DeleteTagParams
|
||||
//
|
||||
// # Delete a tag
|
||||
//
|
||||
// Deletes a tag. This will not delete any messages with this tag.
|
||||
// Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: tag
|
||||
// in: path
|
||||
// description: The url-encoded tag name to delete
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: OKResponse
|
||||
// default: ErrorResponse
|
||||
// 200: OKResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
|
||||
207
server/apiv1/testing.go
Normal file
207
server/apiv1/testing.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/gorilla/mux"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// swagger:parameters GetMessageHTMLParams
|
||||
type getMessageHTMLParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target="_blank"` and `rel="noreferrer noopener"` to all links.
|
||||
//
|
||||
// In addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.
|
||||
//
|
||||
// Note that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// type: string
|
||||
Embed string `json:"embed"`
|
||||
}
|
||||
|
||||
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
|
||||
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.html testing GetMessageHTMLParams
|
||||
//
|
||||
// # Render message HTML part
|
||||
//
|
||||
// Renders just the message's HTML part which can be used for UI integration testing.
|
||||
// Attached inline images are modified to link to the API provided they exist.
|
||||
// Note that is the message does not contain a HTML part then an 404 error is returned.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/html
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
if msg.HTML == "" {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "This message does not contain a HTML part")
|
||||
return
|
||||
}
|
||||
|
||||
htmlStr := linkInlineImages(msg)
|
||||
|
||||
// If embed=1 is set, then we will add target="_blank" and rel="noreferrer noopener" to all links
|
||||
if r.URL.Query().Get("embed") == "1" {
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
} else {
|
||||
// Walk the entire HTML tree.
|
||||
tools.WalkHTML(doc, func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.DataAtom == atom.A {
|
||||
// Set attributes on all anchors with external links.
|
||||
tools.SetHTMLAttributeVal(n, "target", "_blank")
|
||||
tools.SetHTMLAttributeVal(n, "rel", "noreferrer noopener")
|
||||
}
|
||||
})
|
||||
|
||||
b := bytes.Buffer{}
|
||||
_ = html.Render(&b, doc)
|
||||
htmlStr = b.String()
|
||||
|
||||
nonce := r.Header.Get("mp-nonce")
|
||||
|
||||
js := `<script nonce="` + nonce + `">
|
||||
if (typeof window.parent == "object") {
|
||||
window.addEventListener('load', function () {
|
||||
window.parent.postMessage({ messageHeight: document.body.scrollHeight}, "*")
|
||||
})
|
||||
}
|
||||
</script>`
|
||||
|
||||
htmlStr = strings.ReplaceAll(htmlStr, "</body>", js+"</body>")
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(htmlStr))
|
||||
}
|
||||
|
||||
// swagger:parameters GetMessageTextParams
|
||||
type getMessageTextParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
}
|
||||
|
||||
// GetMessageText (method: GET) returns a message's text part
|
||||
func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.txt testing GetMessageTextParams
|
||||
//
|
||||
// # Render message text part
|
||||
//
|
||||
// Renders just the message's text part which can be used for UI integration testing.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// 400: ErrorResponse
|
||||
// 404: NotFoundResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(msg.Text))
|
||||
}
|
||||
|
||||
// This will rewrite all inline image paths to API URLs
|
||||
func linkInlineImages(msg *storage.Message) string {
|
||||
html := msg.HTML
|
||||
|
||||
for _, a := range msg.Inline {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range msg.Attachments {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
@@ -22,35 +22,41 @@ var (
|
||||
thumbHeight = 120
|
||||
)
|
||||
|
||||
// swagger:parameters ThumbnailParams
|
||||
type thumbnailParams struct {
|
||||
// Message database ID or "latest"
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
ID string
|
||||
|
||||
// Attachment part ID
|
||||
//
|
||||
// in: path
|
||||
// required: true
|
||||
PartID string
|
||||
}
|
||||
|
||||
// Thumbnail returns a thumbnail image for an attachment (images only)
|
||||
func Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
|
||||
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message ThumbnailParams
|
||||
//
|
||||
// # Get an attachment image thumbnail
|
||||
//
|
||||
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
|
||||
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - image/jpeg
|
||||
// - image/jpeg
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
// + name: PartID
|
||||
// in: path
|
||||
// description: Attachment part ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: BinaryResponse
|
||||
// default: ErrorResponse
|
||||
// 200: BinaryResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
79
server/embed.go
Normal file
79
server/embed.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed ui
|
||||
distFS embed.FS
|
||||
)
|
||||
|
||||
// EmbedController is a simple controller to return a file from the embedded filesystem.
|
||||
//
|
||||
// This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes
|
||||
// the Content-Encoding header from error responses, breaking pages such as 404's while
|
||||
// using gzip compression middleware.
|
||||
func embedController(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
|
||||
if strings.HasSuffix(p, "/") {
|
||||
p = p + "index.html"
|
||||
}
|
||||
|
||||
p = strings.TrimLeft(p, config.Webroot) // server webroot config
|
||||
p = path.Join("ui", p) // add go:embed path to path prefix
|
||||
|
||||
b, err := distFS.ReadFile(p)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// ensure any HTML files have the correct nonce
|
||||
if strings.HasSuffix(p, ".html") {
|
||||
nonce := r.Header.Get("mp-nonce")
|
||||
b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce))
|
||||
}
|
||||
|
||||
// allow browser cache except for ?dev queries and HTML files
|
||||
if r.URL.RawQuery != "dev" && !strings.HasSuffix(p, ".html") {
|
||||
w.Header().Set("Cache-Control", "max-age=31536000, public, immutable")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType(p))
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// ContentType supports only a few content types, limited to this application's needs.
|
||||
func contentType(p string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(p, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".css"):
|
||||
return "text/css; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".js"):
|
||||
return "application/javascript; charset=utf-8"
|
||||
case strings.HasSuffix(p, ".json"):
|
||||
return "application/json"
|
||||
case strings.HasSuffix(p, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(p, ".ico"):
|
||||
return "image/x-icon"
|
||||
case strings.HasSuffix(p, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(p, ".jpg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(p, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(p, ".woff2"):
|
||||
return "font/woff2"
|
||||
default:
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// RedirectToLatestMessage (method: GET) redirects the web UI to the latest message
|
||||
@@ -44,142 +41,3 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
http.Redirect(w, r, uri, 302)
|
||||
}
|
||||
|
||||
// GetMessageHTML (method: GET) returns a rendered version of a message's HTML part
|
||||
func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.html testing GetMessageHTML
|
||||
//
|
||||
// # Render message HTML part
|
||||
//
|
||||
// Renders just the message's HTML part which can be used for UI integration testing.
|
||||
// Attached inline images are modified to link to the API provided they exist.
|
||||
// Note that is the message does not contain a HTML part then an 404 error is returned.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/html
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
if msg.HTML == "" {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "This message does not contain a HTML part")
|
||||
return
|
||||
}
|
||||
|
||||
html := linkInlineImages(msg)
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// GetMessageText (method: GET) returns a message's text part
|
||||
func GetMessageText(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /view/{ID}.txt testing GetMessageText
|
||||
//
|
||||
// # Render message text part
|
||||
//
|
||||
// Renders just the message's text part which can be used for UI integration testing.
|
||||
//
|
||||
// The ID can be set to `latest` to return the latest message.
|
||||
//
|
||||
// Produces:
|
||||
// - text/plain
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID or latest
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: TextResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
id := vars["id"]
|
||||
|
||||
if id == "latest" {
|
||||
var err error
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, "Message not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(msg.Text))
|
||||
}
|
||||
|
||||
// This will rewrite all inline image paths to API URLs
|
||||
func linkInlineImages(msg *storage.Message) string {
|
||||
html := msg.HTML
|
||||
|
||||
for _, a := range msg.Inline {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range msg.Attachments {
|
||||
if a.ContentID != "" {
|
||||
re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`)
|
||||
u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
for _, m := range matches {
|
||||
html = strings.ReplaceAll(html, m[0], m[1]+u+m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
136
server/server.go
136
server/server.go
@@ -4,12 +4,12 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
@@ -18,21 +18,24 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"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"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
var embeddedFS embed.FS
|
||||
var (
|
||||
// AccessControlAllowOrigin CORS policy
|
||||
AccessControlAllowOrigin string
|
||||
|
||||
// AccessControlAllowOrigin CORS policy
|
||||
var AccessControlAllowOrigin string
|
||||
// htmlPreviewRouteRe is a regexp to match the HTML preview route
|
||||
htmlPreviewRouteRe *regexp.Regexp
|
||||
)
|
||||
|
||||
// Listen will start the httpd
|
||||
func Listen() {
|
||||
@@ -40,12 +43,6 @@ func Listen() {
|
||||
isReady.Store(false)
|
||||
stats.Track()
|
||||
|
||||
serverRoot, err := fs.Sub(embeddedFS, "ui")
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[http] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
websockets.MessageHub = websockets.NewHub()
|
||||
|
||||
go websockets.MessageHub.Run()
|
||||
@@ -62,12 +59,12 @@ func Listen() {
|
||||
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
|
||||
|
||||
// virtual filesystem for /dist/ & some individual files
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
|
||||
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
|
||||
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
|
||||
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
|
||||
|
||||
// redirect to webroot if no trailing slash
|
||||
if config.Webroot != "/" {
|
||||
@@ -79,8 +76,8 @@ func Listen() {
|
||||
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET")
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
@@ -112,11 +109,42 @@ func Listen() {
|
||||
}
|
||||
|
||||
} 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)
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
socketAddr, perm, isSocket := tools.UnixSocket(config.HTTPListen)
|
||||
|
||||
if isSocket {
|
||||
if err := tools.PrepareSocket(socketAddr); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
// delete the Unix socket file on exit
|
||||
storage.AddTempFile(socketAddr)
|
||||
|
||||
ln, err := net.Listen("unix", socketAddr)
|
||||
if err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(socketAddr, perm); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
logger.Log().Infof("[http] starting on %s", config.HTTPListen)
|
||||
|
||||
if err := server.Serve(ln); 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)
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
storage.Close()
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +178,10 @@ func apiRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
|
||||
|
||||
// Chaos
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
|
||||
|
||||
// web UI websocket
|
||||
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
|
||||
|
||||
@@ -196,7 +228,12 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Security-Policy", cspHeader)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
if htmlPreviewRouteRe == nil {
|
||||
htmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\.html$`)
|
||||
}
|
||||
|
||||
if AccessControlAllowOrigin != "" &&
|
||||
(strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI)) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
@@ -220,6 +257,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
fn(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
@@ -228,44 +266,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MiddlewareHandler http middleware adds optional basic authentication
|
||||
// and gzip compression
|
||||
func middlewareHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
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") {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect to webroot
|
||||
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Webroot, http.StatusFound)
|
||||
@@ -279,7 +279,7 @@ func apiWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
|
||||
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
|
||||
f, err := distFS.ReadFile("ui/api/v1/swagger.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -314,7 +314,7 @@ func index(w http.ResponseWriter, r *http.Request) {
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
|
||||
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
|
||||
You need a browser with JavaScript support to use Mailpit
|
||||
You need a browser with JavaScript enabled to use Mailpit
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
@@ -345,6 +345,6 @@ func index(w http.ResponseWriter, r *http.Request) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(buff.Bytes())
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
@@ -50,7 +50,7 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 messages
|
||||
@@ -61,17 +61,17 @@ func TestAPIv1Messages(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// get RAW
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// get headers
|
||||
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
|
||||
m, err := fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// check count of empty database
|
||||
@@ -111,7 +111,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
|
||||
m, err = fetchMessages(ts.URL + "/api/v1/messages")
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
// read first 10 IDs
|
||||
@@ -134,11 +134,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.IDs = putIDS
|
||||
j, err := json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
|
||||
|
||||
@@ -147,11 +147,11 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.Read = false
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
|
||||
|
||||
@@ -160,13 +160,13 @@ func TestAPIv1ToggleReadStatus(t *testing.T) {
|
||||
putData.IDs = []string{}
|
||||
j, err = json.Marshal(putData)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
t.Log("Mark all read")
|
||||
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100)
|
||||
}
|
||||
@@ -243,6 +243,12 @@ func TestAPIv1Send(t *testing.T) {
|
||||
{
|
||||
"Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==",
|
||||
"Filename": "Attached File.txt"
|
||||
},
|
||||
{
|
||||
"Content": "iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==",
|
||||
"Filename": "logo.png",
|
||||
"ContentID": "inline-cid",
|
||||
"ContentType": "overridden/type"
|
||||
}
|
||||
],
|
||||
"ReplyTo": [
|
||||
@@ -266,14 +272,14 @@ func TestAPIv1Send(t *testing.T) {
|
||||
resp := apiv1.SendMessageConfirmation{}
|
||||
|
||||
if err := json.Unmarshal(b, &resp); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(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.Error(err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Testing response for message %s", resp.ID)
|
||||
@@ -294,10 +300,14 @@ func TestAPIv1Send(t *testing.T) {
|
||||
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")
|
||||
assertEqual(t, `text/plain`, msg.Attachments[0].ContentType, "wrong Content-Type")
|
||||
assertEqual(t, 1, len(msg.Inline), "wrong inline Attachment count")
|
||||
assertEqual(t, `logo.png`, msg.Inline[0].FileName, "wrong Attachment name")
|
||||
assertEqual(t, `overridden/type`, msg.Inline[0].ContentType, "wrong Content-Type")
|
||||
|
||||
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
@@ -321,12 +331,12 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
|
||||
|
||||
data, err := clientGet(uri)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -342,12 +352,12 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
|
||||
|
||||
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
|
||||
filteredTo := []string{}
|
||||
for _, address := range to {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
|
||||
logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address)
|
||||
continue
|
||||
}
|
||||
|
||||
filteredTo = append(filteredTo, address)
|
||||
}
|
||||
to = filteredTo
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import CommonMixins from './mixins/CommonMixins'
|
||||
import Favicon from './components/Favicon.vue'
|
||||
import Notifications from './components/Notifications.vue'
|
||||
import EditTags from './components/EditTags.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { mailbox } from "./stores/mailbox"
|
||||
|
||||
export default {
|
||||
@@ -16,7 +15,6 @@ export default {
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
|
||||
// load global config
|
||||
this.get(this.resolve('/api/v1/webui'), false, function (response) {
|
||||
mailbox.uiConfig = response.data
|
||||
|
||||
@@ -6,6 +6,7 @@ import mitt from 'mitt';
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
import 'vue-css-donut-chart/src/styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -314,10 +314,6 @@ body.blur {
|
||||
> div {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,12 @@ export default {
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
mailbox.notificationsEnabled = true
|
||||
}
|
||||
|
||||
this.modal('EnableNotificationsModal').hide()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -55,7 +57,7 @@ export default {
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
@@ -147,7 +149,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<div class="card border-secondary">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
@@ -239,8 +241,9 @@ export default {
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
|
||||
v-on:click="requestNotifications">Enable notifications</button>
|
||||
<button type="button" class="btn btn-success" v-on:click="requestNotifications">
|
||||
Enable notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,27 +125,27 @@ export default {
|
||||
: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)">
|
||||
@click.meta="toggleSelected($event, message.ID)" @click.ctrl="toggleSelected($event, message.ID)"
|
||||
@click.shift="selectRange($event, message.ID)">
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="'From: ' + message.From.Address">
|
||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
||||
</span>
|
||||
<div v-if="message.From" class="overflow-x-hidden">
|
||||
<div class="text-truncate privacy">
|
||||
<b :title="'From: ' + message.From.Address">
|
||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-lg-block privacy">
|
||||
<b v-if="message.From" :title="'From: ' + message.From.Address">
|
||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
||||
</b>
|
||||
</div>
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
<div class="overflow-x-hidden">
|
||||
<div class="text-truncate text-muted small privacy">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
|
||||
|
||||
@@ -65,9 +65,10 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||
v-if="mailbox.uiConfig.Label">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
@@ -82,13 +83,23 @@ export default {
|
||||
</button>
|
||||
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.unread" @click="markAllRead">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
|
||||
<i class="bi bi-eye-fill me-1"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
:disabled="!mailbox.total" @click="deleteAllMessages">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.total">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
|
||||
@@ -52,9 +52,10 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||
v-if="mailbox.uiConfig.Label">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
@@ -67,7 +68,12 @@ export default {
|
||||
</span>
|
||||
</RouterLink>
|
||||
<template v-if="!mailbox.selected.length">
|
||||
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
<button v-if="mailbox.skipConfirmations" class="list-group-item list-group-item-action"
|
||||
@click="deleteAllMessages" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</button>
|
||||
<button v-else class="list-group-item list-group-item-action" data-bs-toggle="modal"
|
||||
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`\\btag:("${tag}"|${tag}\\b)`, 'i')
|
||||
let re = new RegExp(`(^|\\s)tag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
<template>
|
||||
<template v-if="mailbox.tags && mailbox.tags.length">
|
||||
<div class="mt-4 text-muted">
|
||||
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@@ -99,8 +99,8 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
|
||||
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click.exact="hideNav"
|
||||
@click="pagination.start = 0" @click.meta="toggleTag($event, tag)" @click.ctrl="toggleTag($event, tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
|
||||
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
@@ -39,6 +40,8 @@ export default {
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -99,6 +102,9 @@ export default {
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +201,43 @@ export default {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header" v-if="toastMessage">
|
||||
<i class="bi bi-envelope-exclamation-fill me-2"></i>
|
||||
|
||||
@@ -12,6 +12,8 @@ export default {
|
||||
mailbox,
|
||||
theme: localStorage.getItem('theme') ? localStorage.getItem('theme') : 'auto',
|
||||
timezones,
|
||||
chaosConfig: false,
|
||||
chaosUpdated: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,7 +25,23 @@ export default {
|
||||
localStorage.setItem('theme', v)
|
||||
}
|
||||
this.setTheme()
|
||||
},
|
||||
|
||||
chaosConfig: {
|
||||
handler() {
|
||||
this.chaosUpdated = true
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
|
||||
'mailbox.skipConfirmations'(v) {
|
||||
if (v) {
|
||||
localStorage.setItem('skip-confirmations', 'true')
|
||||
} else {
|
||||
localStorage.removeItem('skip-confirmations')
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
@@ -31,6 +49,8 @@ export default {
|
||||
this.$nextTick(function () {
|
||||
Tags.init('select.tz')
|
||||
})
|
||||
|
||||
mailbox.skipConfirmations = localStorage.getItem('skip-confirmations') ? true : false
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -44,6 +64,24 @@ export default {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.theme)
|
||||
}
|
||||
},
|
||||
|
||||
loadChaos() {
|
||||
this.get(this.resolve('/api/v1/chaos'), null, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
saveChaos() {
|
||||
this.put(this.resolve('/api/v1/chaos'), this.chaosConfig, (response) => {
|
||||
this.chaosConfig = response.data
|
||||
this.$nextTick(() => {
|
||||
this.chaosUpdated = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -54,64 +92,199 @@ export default {
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit UI settings</h5>
|
||||
<h5 class="modal-title" id="SettingsModalLabel">Mailpit 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>
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="ui-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#ui-tab-pane" type="button" role="tab" aria-controls="ui-tab-pane"
|
||||
aria-selected="true">Web UI</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chaos-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#chaos-tab-pane" type="button" role="tab" aria-controls="chaos-tab-pane"
|
||||
aria-selected="false" @click="loadChaos">Chaos</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="ui-tab-pane" role="tabpanel" aria-labelledby="ui-tab"
|
||||
tabindex="0">
|
||||
<div class="my-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 class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="skip-confirmations" v-model="mailbox.skipConfirmations">
|
||||
<label class="form-check-label" for="skip-confirmations">
|
||||
Skip <code>Delete all</code> & <code>Mark all read</code> confirmation
|
||||
dialogs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</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 class="tab-pane fade" id="chaos-tab-pane" role="tabpanel" aria-labelledby="chaos-tab"
|
||||
tabindex="0" v-if="mailbox.uiConfig.ChaosEnabled">
|
||||
<p class="my-3">
|
||||
<b>Chaos</b> allows you to set random SMTP failures and response codes at various
|
||||
stages in a SMTP transaction to test application resilience
|
||||
(<a href="https://mailpit.axllent.org/docs/integration/chaos/" target="_blank">
|
||||
see documentation
|
||||
</a>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>Response code</code> is the SMTP error code returned by the server if this
|
||||
error is triggered. Error codes must range between 400 and 599.
|
||||
</li>
|
||||
<li>
|
||||
<code>Error probability</code> is the % chance that the error will occur per message
|
||||
delivery, where <code>0</code>(%) is disabled and <code>100</code>(%) wil always
|
||||
trigger. A probability of <code>50</code> will trigger on approximately 50% of
|
||||
messages received.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="chaosConfig">
|
||||
<div class="mt-4 mb-4" :class="chaosUpdated ? 'was-validated' : ''">
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Sender</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the sender (From / Sender).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Sender.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Sender.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Sender.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Recipient</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an error response based on the recipients (To, Cc, Bcc).
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Recipient.ErrorCode" min="400" max="599"
|
||||
required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Recipient.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Recipient.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>Trigger: <code>Authentication</code></label>
|
||||
<div class="form-text">
|
||||
Trigger an authentication error response.
|
||||
Note that SMTP authentication must be configured too.
|
||||
</div>
|
||||
<div class="row mt-1">
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Response code
|
||||
</label>
|
||||
<input type="number" class="form-control"
|
||||
v-model.number="chaosConfig.Authentication.ErrorCode" min="400"
|
||||
max="599" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">
|
||||
Error probability ({{ chaosConfig.Authentication.Probability }}%)
|
||||
</label>
|
||||
<input type="range" class="form-range mt-1" min="0" max="100"
|
||||
v-model.number="chaosConfig.Authentication.Probability">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chaosUpdated" class="mb-3 text-center">
|
||||
<button class="btn btn-success" @click="saveChaos">Update Chaos</button>
|
||||
</div>
|
||||
</template>
|
||||
</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 class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
@@ -299,7 +299,7 @@ export default {
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
|
||||
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
|
||||
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
@@ -327,7 +327,7 @@ export default {
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
</template>
|
||||
</Donut>
|
||||
</vc-donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
|
||||
@@ -78,8 +78,9 @@ export default {
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
// set target & rel on all links
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#') {
|
||||
if (node.tagName != 'A' || (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#')) {
|
||||
return
|
||||
}
|
||||
if ('target' in node) {
|
||||
@@ -115,7 +116,7 @@ export default {
|
||||
'vertical-align',
|
||||
'vlink',
|
||||
'vspace',
|
||||
'xml:lang'
|
||||
'xml:lang',
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
@@ -182,7 +183,7 @@ export default {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
// delay 0.5s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
@@ -193,14 +194,14 @@ export default {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^http/)) {
|
||||
if (href && href.match(/^https?:\/\//i)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
this.resizeIFrames()
|
||||
}
|
||||
}, 200)
|
||||
}, 500)
|
||||
|
||||
// html highlighting
|
||||
window.Prism = window.Prism || {}
|
||||
@@ -457,134 +458,139 @@ export default {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3">
|
||||
<div class="mt-2 mt-md-0" v-if="allAttachments(message)">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2">
|
||||
Attachment<span v-if="allAttachments(message).length > 1">s</span>
|
||||
({{ allAttachments(message).length }})
|
||||
<div class="col-md-auto d-none d-md-block text-end mt-md-3"
|
||||
v-if="message.Attachments && message.Attachments.length || message.Inline && message.Inline.length">
|
||||
<div class="mt-2 mt-md-0">
|
||||
<template v-if="message.Attachments.length">
|
||||
<span class="badge rounded-pill text-bg-secondary p-2 mb-2" title="Attachments in this message">
|
||||
Attachment<span v-if="message.Attachments.length > 1">s</span>
|
||||
({{ message.Attachments.length }})
|
||||
</span>
|
||||
<br>
|
||||
</template>
|
||||
<span class="badge rounded-pill text-bg-secondary p-2" v-if="message.Inline.length"
|
||||
title="Inline images in this message">
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
||||
({{ message.Inline.length }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
<nav class="nav nav-tabs my-3 d-print-none" id="nav-tab" role="tablist">
|
||||
<template v-if="message.HTML">
|
||||
<div class="btn-group">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html"
|
||||
type="button" role="tab" aria-controls="nav-html" aria-selected="true" ref="navhtml"
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
</button>
|
||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
||||
HTML Source
|
||||
</button>
|
||||
<button type="button" class="nav-link dropdown-toggle dropdown-toggle-split d-sm-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false">
|
||||
HTML Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</div>
|
||||
<button class="nav-link d-none d-sm-inline" id="nav-html-source-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
|
||||
aria-selected="false">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
Text
|
||||
</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div 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 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">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</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">
|
||||
Link Check
|
||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.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">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
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>
|
||||
</span>
|
||||
</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" 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">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||
aria-selected="false" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="_, 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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
|
||||
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
|
||||
:class="message.HTML == '' ? 'show' : ''">
|
||||
Text
|
||||
</button>
|
||||
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
|
||||
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
|
||||
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
|
||||
</button>
|
||||
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<div 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 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">
|
||||
HTML Check
|
||||
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||
v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
</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">
|
||||
Link Check
|
||||
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||
<small>0</small>
|
||||
</span>
|
||||
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="mailbox.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">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||
v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||
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>
|
||||
</span>
|
||||
</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" 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">
|
||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab"
|
||||
aria-controls="nav-html" aria-selected="false"
|
||||
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
|
||||
Spam Analysis
|
||||
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||
<small>{{ spamScore }}</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="_, 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>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ export default {
|
||||
<div class="row">
|
||||
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
<input v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" type="text"
|
||||
aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="mailbox.uiConfig.MessageRelay.OverrideFrom">
|
||||
<input v-else type="text" aria-label="From address" readonly class="form-control-plaintext"
|
||||
:value="message.From ? message.From.Address : ''">
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,51 +125,39 @@ export default {
|
||||
Delete the message after release
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h6>Notes</h6>
|
||||
<ul>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''" class="form-text">
|
||||
A recipient <b>allowlist</b> has been configured. Any mail address not matching the following will be rejected:
|
||||
A recipient <b>allowlist</b> has been configured. Any mail address not matching the
|
||||
following will be rejected:
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</code>
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''" class="form-text">
|
||||
A recipient <b>blocklist</b> has been configured. Any mail address matching the following will be rejected:
|
||||
A recipient <b>blocklist</b> has been configured. Any mail address matching the following
|
||||
will be rejected:
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</code>
|
||||
</li>
|
||||
<li class="form-text">
|
||||
For testing purposes, a new unique <code>Message-Id</code> will be generated on send.
|
||||
</li>
|
||||
<li v-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''" class="form-text">
|
||||
The <code>From</code> email address has been overridden by the relay configuration to
|
||||
<code>{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}</code>.
|
||||
</li>
|
||||
<li class="form-text">
|
||||
SMTP delivery failures will bounce back to
|
||||
<code v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
|
||||
</code>
|
||||
<code v-else-if="mailbox.uiConfig.MessageRelay.OverrideFrom != ''">
|
||||
{{ mailbox.uiConfig.MessageRelay.OverrideFrom }}
|
||||
</code>
|
||||
<code v-else>{{ message.ReturnPath }}</code>.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- <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" v-if="mailbox.uiConfig.MessageRelay.BlockedRecipients != ''">
|
||||
Note: A recipient blocklist has been configured. Any mail address matching it will be
|
||||
rejected.<br class="d-none d-md-inline">
|
||||
Blocked recipients: <b>{{ mailbox.uiConfig.MessageRelay.BlockedRecipients }}</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-else>{{ message.ReturnPath }}</b>.
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import { VcDonut } from 'vue-css-donut-chart'
|
||||
import axios from 'axios'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
VcDonut,
|
||||
},
|
||||
|
||||
emits: ["setSpamScore", "setBadgeStyle"],
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
<template v-else-if="check">
|
||||
<div class="row w-100 mt-5">
|
||||
<div class="col-xl-5 mb-2">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
<vc-donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ check.Score }} / 5
|
||||
@@ -165,7 +165,7 @@ export default {
|
||||
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||
</div>
|
||||
</Donut>
|
||||
</vc-donut>
|
||||
</div>
|
||||
<div class="col-xl-7">
|
||||
<div class="row w-100 py-2 border-bottom">
|
||||
|
||||
@@ -14,8 +14,9 @@ export const mailbox = reactive({
|
||||
searching: false, // current search, false for none
|
||||
refresh: false, // to listen from MessagesMixin
|
||||
autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false,
|
||||
notificationsSupported: false, // browser supports notifications
|
||||
notificationsEnabled: false, // user has enabled notifications
|
||||
skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read"
|
||||
appInfo: {}, // application information
|
||||
uiConfig: {}, // configuration for UI
|
||||
lastMessage: false, // return scrolling
|
||||
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
|
||||
@@ -224,7 +224,6 @@ export default {
|
||||
|
||||
this.liveLoaded++
|
||||
this.messagesList.unshift(data)
|
||||
this.scrollSidebarToCurrent()
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
@@ -451,7 +450,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
@@ -505,16 +504,41 @@ export default {
|
||||
Text body
|
||||
</button>
|
||||
</li>
|
||||
<template v-if="allAttachments(message).length">
|
||||
<template v-if="message.Attachments && message.Attachments.length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Attachment<template v-if="allAttachments(message).length > 1">s</template>
|
||||
Attachments
|
||||
</h6>
|
||||
</li>
|
||||
<li v-for="part in allAttachments(message)">
|
||||
<li v-for="part in message.Attachments">
|
||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
<div class="col-auto p-0 pe-1">
|
||||
<i class="bi" :class="attachmentIcon(part)"></i>
|
||||
</div>
|
||||
<div class="col text-truncate p-0 pe-1">
|
||||
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
|
||||
</div>
|
||||
<div class="col-auto text-muted small p-0">
|
||||
{{ getFileSize(part.Size) }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
<template v-if="message.Inline && message.Inline.length">
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">
|
||||
Inline image<span v-if="message.Inline.length > 1">s</span>
|
||||
</h6>
|
||||
</li>
|
||||
<li v-for="part in message.Inline">
|
||||
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
|
||||
class="row m-0 dropdown-item d-flex" target="_blank"
|
||||
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
|
||||
@@ -545,9 +569,10 @@ export default {
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||
v-if="mailbox.uiConfig.Label">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
<div class="text-center badge text-bg-primary py-2 my-2 w-100" v-if="mailbox.uiConfig.Label">
|
||||
<div class="text-truncate fw-normal">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
@@ -576,10 +601,18 @@ export default {
|
||||
:id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="col overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
<strong v-if="message.From" :title="'From: ' + message.From.Address">
|
||||
{{ message.From.Name ? message.From.Name : message.From.Address }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto small">
|
||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
@@ -587,9 +620,10 @@ export default {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto small">
|
||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
<div class="col-12 overflow-x-hidden mt-1">
|
||||
<div class="text-truncates small">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.Tags.length" class="col-12">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white d-print-none">
|
||||
<div class="col-xl-2 col-md-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive">
|
||||
<link rel="icon" href="../../favicon.svg">
|
||||
<script src="../../dist/docs.js"></script>
|
||||
<script src="../../dist/docs.js" nonce="%%NONCE%%"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<rapi-doc id="thedoc" spec-url="swagger.json" theme="light" layout="column" render-style="read" load-fonts="false"
|
||||
allow-authentication="false" sort-tags="true"
|
||||
regular-font="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'"
|
||||
mono-font="Courier New, Courier, System, fixed-width" font-size="large" allow-spec-url-load="false"
|
||||
allow-spec-file-load="false" allow-server-selection="false" allow-search="false" allow-advanced-search="false"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,10 @@ func Send(msg interface{}) {
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if config.Label != "" {
|
||||
req.Header.Set("Mailpit-Label", config.Label)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,3 +90,20 @@ func Broadcast(t string, msg interface{}) {
|
||||
|
||||
go func() { MessageHub.Broadcast <- b }()
|
||||
}
|
||||
|
||||
// BroadCastClientError is a wrapper to broadcast client errors to the web UI
|
||||
func BroadCastClientError(severity, errorType, ip, message string) {
|
||||
msg := struct {
|
||||
Level string
|
||||
Type string
|
||||
IP string
|
||||
Message string
|
||||
}{
|
||||
severity,
|
||||
errorType,
|
||||
ip,
|
||||
message,
|
||||
}
|
||||
|
||||
Broadcast("error", msg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user