mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 21:57:00 +00:00
Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3e7e701f | ||
|
|
f1d0bcda90 | ||
|
|
4f651e4f14 | ||
|
|
c3819ca26d | ||
|
|
4febeb1acd | ||
|
|
10ad4df8cc | ||
|
|
632113fcc5 | ||
|
|
08ed46fc46 | ||
|
|
6927c2b73b | ||
|
|
ac81da5ae0 | ||
|
|
f1d55e4e39 | ||
|
|
b622252411 | ||
|
|
5527379475 | ||
|
|
1d87f1164e | ||
|
|
b4ca68eb48 | ||
|
|
971ae95a67 | ||
|
|
c8caa29e24 | ||
|
|
7d314d2b50 | ||
|
|
9d2f30787a | ||
|
|
b9d071db81 | ||
|
|
a5ee550ba3 | ||
|
|
3e41beb214 | ||
|
|
43b8ba3dc6 | ||
|
|
d41eca3df7 | ||
|
|
e6fd638067 | ||
|
|
e2b1b2d0fe | ||
|
|
9b4ec97483 | ||
|
|
e735904167 | ||
|
|
94113222cc | ||
|
|
5414695508 | ||
|
|
dd74d46880 | ||
|
|
0bfbb4cc5f | ||
|
|
38c0c4fd47 | ||
|
|
9391b075d0 | ||
|
|
a87b2a9455 | ||
|
|
8d18618e4a | ||
|
|
a63bcd9bd3 | ||
|
|
f33f9bec2d | ||
|
|
ff47ba96b8 | ||
|
|
b9f36312d7 | ||
|
|
291c449591 | ||
|
|
d7a4a60536 | ||
|
|
464ff68c34 | ||
|
|
9383c5876b | ||
|
|
a3616e52d9 | ||
|
|
980e54c21f | ||
|
|
eac491cd89 | ||
|
|
12076bca72 | ||
|
|
028ca1d715 | ||
|
|
7d7ba88e9c | ||
|
|
973fc1f975 | ||
|
|
1679a0aba5 | ||
|
|
4a4c149eed | ||
|
|
c01335f0e3 | ||
|
|
181cb0714a | ||
|
|
00d52d5931 | ||
|
|
050da038af | ||
|
|
36cc06c125 | ||
|
|
2734efbc66 | ||
|
|
7cda4a36f1 | ||
|
|
45b3676e52 | ||
|
|
d50347d667 | ||
|
|
c035139b54 | ||
|
|
3108d82e06 | ||
|
|
648d5863da | ||
|
|
585ea1dc30 | ||
|
|
c66a06379a | ||
|
|
c5c9292863 | ||
|
|
6f1f4f34c9 | ||
|
|
877a9159ce | ||
|
|
c4582889ad | ||
|
|
cd1cf695b9 | ||
|
|
392904fd23 | ||
|
|
f0160c0e29 | ||
|
|
f9024d1f77 | ||
|
|
061f159293 | ||
|
|
e69a0d75c9 | ||
|
|
0847167694 | ||
|
|
6dd3587ec6 | ||
|
|
2d1e38d4fd | ||
|
|
153174f928 | ||
|
|
3b9b470c09 | ||
|
|
dd99a4bcf0 | ||
|
|
5bf2f2796b | ||
|
|
a469655f65 | ||
|
|
432fedeafa | ||
|
|
37e4ff4139 | ||
|
|
2808316dd2 | ||
|
|
43d759b0fc | ||
|
|
264222d599 | ||
|
|
5e4bdb78b8 | ||
|
|
fc9572156b | ||
|
|
d52a0d550f | ||
|
|
fcce621f18 | ||
|
|
f4cd19aac2 | ||
|
|
46ccf866b2 | ||
|
|
266611fda0 | ||
|
|
fe3920e3c6 | ||
|
|
ac02802d62 | ||
|
|
7d6aab4e01 | ||
|
|
36d8525557 | ||
|
|
0f0a5d942f | ||
|
|
b987006897 | ||
|
|
c8e0bee8bb | ||
|
|
3b0ae24c2a | ||
|
|
aca491f10c | ||
|
|
6724f0ccdd | ||
|
|
93088f3361 | ||
|
|
e817bf5f7d | ||
|
|
4d100a9ec3 | ||
|
|
958fa6cf1a | ||
|
|
27e12474f5 | ||
|
|
302b269fb6 | ||
|
|
2d9157ffd3 | ||
|
|
242c96244a | ||
|
|
d308e7f30b | ||
|
|
85a9cc3c2b | ||
|
|
f94ce556e5 | ||
|
|
5ad8619893 | ||
|
|
8d692b6bd9 | ||
|
|
99ab38fbba | ||
|
|
2cf040e787 | ||
|
|
cde80bf0fd | ||
|
|
1a41d433c6 | ||
|
|
49557e8e59 | ||
|
|
339f6ef31d | ||
|
|
2e187cfcef | ||
|
|
39ecefa108 | ||
|
|
ae65312d02 | ||
|
|
0770bd8d19 | ||
|
|
e2314fb3b9 | ||
|
|
1dd0bf3d29 | ||
|
|
568ad5da62 | ||
|
|
d3063ea248 | ||
|
|
6ccc294a1e | ||
|
|
5629f39d40 | ||
|
|
314f30bee5 | ||
|
|
5c8931c04d | ||
|
|
ca6357f262 | ||
|
|
3645219615 | ||
|
|
7d74516270 | ||
|
|
fb1a06bc86 | ||
|
|
7809a26374 | ||
|
|
120f078a32 | ||
|
|
776912d38a | ||
|
|
e3b37943a8 | ||
|
|
5054d98701 | ||
|
|
8ce6fc0db5 | ||
|
|
933d064a51 | ||
|
|
ad26ca34e5 | ||
|
|
531801f934 | ||
|
|
0faa71310e | ||
|
|
646e93c58b | ||
|
|
854844924f | ||
|
|
343db8bb61 | ||
|
|
781d8d2332 | ||
|
|
618d1f77b5 | ||
|
|
5577b748af | ||
|
|
4619d9be88 | ||
|
|
6051952a9b | ||
|
|
a54697b3de | ||
|
|
f6bb0d1ffd | ||
|
|
41ef4ecd60 | ||
|
|
39d80df809 | ||
|
|
be95839838 | ||
|
|
8f187fe275 | ||
|
|
bb2793354a | ||
|
|
42aa38ddeb | ||
|
|
7423b9660b | ||
|
|
0b7503261c | ||
|
|
e43be79968 | ||
|
|
70855a50c5 | ||
|
|
0b21a3aba2 | ||
|
|
507217844b | ||
|
|
5a4d13b15a | ||
|
|
fbc1dc6118 | ||
|
|
2a7aa33a0a | ||
|
|
45f07d3c9b | ||
|
|
894220dc44 | ||
|
|
cce21854b9 | ||
|
|
33fe814c34 | ||
|
|
df75064009 | ||
|
|
e1ed21abff | ||
|
|
f3e3536cdb | ||
|
|
38c343867e | ||
|
|
75504c7bba | ||
|
|
79323df3bd | ||
|
|
5a672df0fc | ||
|
|
72730ba470 | ||
|
|
76d9a410b8 | ||
|
|
7ca84d3b0d | ||
|
|
d430e38aad | ||
|
|
2d1fb7cf14 | ||
|
|
8634c9e8f2 | ||
|
|
e74237e71c | ||
|
|
1f1eed8a8b | ||
|
|
643a7ed9d5 | ||
|
|
b6f7833805 | ||
|
|
b612ac948c | ||
|
|
7b805ef7cd | ||
|
|
7c7d915059 | ||
|
|
79e9439858 | ||
|
|
cc5991c038 | ||
|
|
e29883fa1c | ||
|
|
f99d9ecf69 | ||
|
|
429d2e2b3a | ||
|
|
91f0515b48 | ||
|
|
88e1aa324b | ||
|
|
a7e27ea9b7 | ||
|
|
796749e1a1 | ||
|
|
91e4a87995 | ||
|
|
3ad7623e84 | ||
|
|
f4954ba115 | ||
|
|
4195f30d95 | ||
|
|
690e82cbfd | ||
|
|
2d42c87285 | ||
|
|
c208d71a33 | ||
|
|
3cacede2d7 | ||
|
|
1886277b6e | ||
|
|
3fff79e29f | ||
|
|
7dee371721 | ||
|
|
95e3ef6fca | ||
|
|
f88a42fda4 | ||
|
|
3aae06ff6b | ||
|
|
4b5ce0afed | ||
|
|
d4ee6fd987 | ||
|
|
29f4a10d89 | ||
|
|
8f7bf25022 | ||
|
|
a1110e5ad8 | ||
|
|
31a2ed8824 | ||
|
|
f675ef7b5e | ||
|
|
4257a89584 | ||
|
|
52957cd81f | ||
|
|
1520143c45 | ||
|
|
5107ce0191 | ||
|
|
40afef8ffd | ||
|
|
fed20de522 | ||
|
|
6999b2ea02 | ||
|
|
72e92d2d1e | ||
|
|
803adf29ac | ||
|
|
fb0230a460 | ||
|
|
873193bcec | ||
|
|
e3538cb86a | ||
|
|
e6ab9e1008 | ||
|
|
86f3546bfe | ||
|
|
a6b5f5f76b | ||
|
|
82d7bdc971 | ||
|
|
020d5b0fcb | ||
|
|
f2b91ac9d5 | ||
|
|
4dff7adc1d | ||
|
|
9bfdeb5f7b | ||
|
|
c5b3edf87d | ||
|
|
8c59229f97 | ||
|
|
56739ceac2 | ||
|
|
5240b1b33e | ||
|
|
8f80a57c3c | ||
|
|
04ea905619 | ||
|
|
b84b428434 | ||
|
|
91409310d7 | ||
|
|
99a3e17243 | ||
|
|
ff272d1c5e | ||
|
|
74c6a0a434 | ||
|
|
e16267ab50 | ||
|
|
8d86b39385 | ||
|
|
38914348a5 | ||
|
|
25580b9a68 | ||
|
|
a1c2690c44 | ||
|
|
ff8b6326ab | ||
|
|
5d2966d726 | ||
|
|
bf5609a39b | ||
|
|
4ed5011a8f | ||
|
|
68d911431f | ||
|
|
d0716b4995 | ||
|
|
84a519e84d | ||
|
|
e1a6904eca | ||
|
|
bc200c663f | ||
|
|
009f3a8fd9 | ||
|
|
cfe695c35d |
@@ -1,47 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
{{ if .Versions -}}
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
## [Unreleased]
|
||||
|
||||
{{ if .Unreleased.CommitGroups -}}
|
||||
{{ range .Unreleased.CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
## [{{ .Tag.Name }}]
|
||||
|
||||
{{ if .NoteGroups -}}
|
||||
{{ range .NoteGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Notes }}
|
||||
{{ .Body }}
|
||||
{{ end -}}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{- if .MergeCommits -}}
|
||||
### Pull Requests
|
||||
{{ range .MergeCommits -}}
|
||||
- {{ .Header }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
@@ -1,12 +0,0 @@
|
||||
{{ if .Versions -}}
|
||||
{{ range .Versions }}
|
||||
{{- if .CommitGroups -}}
|
||||
{{ range .CommitGroups -}}
|
||||
### {{ .Title }}
|
||||
{{ range .Commits -}}
|
||||
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
|
||||
{{ end }}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
{{ end -}}
|
||||
@@ -1,46 +0,0 @@
|
||||
style: github
|
||||
template: CHANGELOG.tpl.md
|
||||
info:
|
||||
title: CHANGELOG
|
||||
repository_url: https://github.com/axllent/mailpit
|
||||
options:
|
||||
commits:
|
||||
# filters:
|
||||
# Type:
|
||||
# - feat
|
||||
# - fix
|
||||
# - perf
|
||||
# - refactor
|
||||
commit_groups:
|
||||
title_maps:
|
||||
feature: Feature
|
||||
fix: Fix
|
||||
# perf: Performance Improvements
|
||||
# refactor: Code Refactoring
|
||||
sort_by: Custom
|
||||
title_order:
|
||||
- Feature
|
||||
- Chore
|
||||
- UI
|
||||
- API
|
||||
- Libs
|
||||
- Docker
|
||||
- Security
|
||||
- Fix
|
||||
- Bugfix
|
||||
- Docs
|
||||
- Swagger
|
||||
- Build
|
||||
- Testing
|
||||
- Test
|
||||
- Tests
|
||||
- Pull Requests
|
||||
header:
|
||||
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
|
||||
pattern_maps:
|
||||
- Type
|
||||
- Scope
|
||||
- Subject
|
||||
notes:
|
||||
keywords:
|
||||
- BREAKING CHANGE
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [axllent]
|
||||
thanks_dev: u/gh/axllent
|
||||
|
||||
21
.github/SECURITY.md
vendored
Normal file
21
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
If you believe you have found a security vulnerability, you can report it using one of the following methods:
|
||||
|
||||
1. **GitHub Security Advisory (Recommended):** Use the "Report a vulnerability" button in the [Security tab](../../security/advisories/new) of this repository.
|
||||
2. **Email:** Send your findings to security@axllent.org
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will be kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
49
.github/cliff.toml
vendored
Normal file
49
.github/cliff.toml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
## https://git-cliff.org/
|
||||
[changelog]
|
||||
body = """
|
||||
{% if version %}\
|
||||
\n## [{{ version }}]
|
||||
{% else %}\
|
||||
\n## Unreleased
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}\
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = ""
|
||||
header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file."
|
||||
postprocessors = [
|
||||
{pattern = "reponse", replace = "response"},
|
||||
{pattern = "messsage", replace = "message"},
|
||||
{pattern = '(?i) go modules', replace = " Go dependencies"},
|
||||
{pattern = '(?i) node modules', replace = " node dependencies"},
|
||||
{pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"},
|
||||
]
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# HTML comments added for grouping order, stripped on generation
|
||||
commit_parsers = [
|
||||
{body = ".*security", group = "<!-- 1 -->Security"},
|
||||
{message = "(?i)^security", group = "<!-- 1 -->Security"},
|
||||
{message = "(?i)^feat", group = "<!-- 2 -->Feature"},
|
||||
{message = "(?i)^chore", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^libs", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^ui", group = "<!-- 3 -->Chore"},
|
||||
{message = "(?i)^api", group = "<!-- 4 -->API"},
|
||||
{message = "(?i)^fix", group = "<!-- 5 -->Fix"},
|
||||
{message = "(?i)^doc", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^swagger", group = "<!-- 6 -->Documentation", default_scope = "unscoped"},
|
||||
{message = "(?i)^test", group = "<!-- 7 -->Test"},
|
||||
]
|
||||
|
||||
# Exclude commits that are not matched by any commit parser.
|
||||
# filter_commits = true
|
||||
# Order releases topologically instead of chronologically.
|
||||
# topo_order = true
|
||||
# Order of commits in each group/release within the changelog.
|
||||
# Allowed values: newest, oldest
|
||||
sort_commits = "oldest"
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -8,16 +8,16 @@ updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "quarterly"
|
||||
|
||||
2
.github/workflows/build-docker-edge.yml
vendored
2
.github/workflows/build-docker-edge.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
3
.github/workflows/close-stale-issues.yml
vendored
3
.github/workflows/close-stale-issues.yml
vendored
@@ -10,12 +10,13 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9.1.0
|
||||
- uses: actions/stale@v10.1.1
|
||||
with:
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 3
|
||||
exempt-issue-labels: "enhancement,bug,awaiting feedback"
|
||||
stale-issue-label: "stale"
|
||||
close-issue-reason: "completed"
|
||||
stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity."
|
||||
close-issue-message: "This issue was closed because there has been no activity since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
4
.github/workflows/release-build.yml
vendored
4
.github/workflows/release-build.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
- goarch: arm
|
||||
goos: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# build the assets
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
29
.github/workflows/tests-rqlite.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Tests (rqlite)
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
push:
|
||||
branches: [ develop, 'feature/**' ]
|
||||
|
||||
jobs:
|
||||
test-rqlite:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
rqlite:
|
||||
image: rqlite/rqlite:latest
|
||||
ports:
|
||||
- 4001:4001
|
||||
env:
|
||||
# the HTTP address the rqlite node should advertise
|
||||
HTTP_ADV_ADDR: "localhost:4001"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache-dependency-path: "**/*.sum"
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
env:
|
||||
# set Mailpit to use the rqlite service container
|
||||
MP_DATABASE: "http://localhost:4001"
|
||||
46
.github/workflows/tests.yml
vendored
46
.github/workflows/tests.yml
vendored
@@ -8,17 +8,17 @@ jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.23']
|
||||
go-version: [stable]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: false
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Go tests
|
||||
uses: actions/cache@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Go environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -26,24 +26,36 @@ jobs:
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
- name: Test Go linting (gofmt)
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
# https://olegk.dev/github-actions-and-go
|
||||
run: gofmt -s -w . && git diff --exit-code
|
||||
- name: Run Go tests
|
||||
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
|
||||
- name: Run Go benchmarking
|
||||
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
|
||||
|
||||
# build the assets
|
||||
- name: Build web UI
|
||||
- name: Set up node environment
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
- name: Install JavaScript dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm install
|
||||
- if: startsWith(matrix.os, 'ubuntu') == true
|
||||
- name: Run JavaScript linting
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run lint
|
||||
- name: Test JavaScript packaging
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
run: npm run package
|
||||
|
||||
# validate the swagger file
|
||||
- name: Validate OpenAPI definition
|
||||
if: startsWith(matrix.os, 'ubuntu') == true
|
||||
uses: swaggerexpert/swagger-editor-validate@v1
|
||||
with:
|
||||
definition-file: server/ui/api/v1/swagger.json
|
||||
# # validate the swagger file
|
||||
# - name: Validate OpenAPI definition
|
||||
# if: startsWith(matrix.os, 'ubuntu') == true
|
||||
# uses: swaggerexpert/swagger-editor-validate@v1
|
||||
# with:
|
||||
# definition-file: server/ui/api/v1/swagger.json
|
||||
# default-timeout: 20000
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Not within the scope of Prettier
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.json
|
||||
**/*.md
|
||||
**/*.css
|
||||
**/*.html
|
||||
**/*.scss
|
||||
composer.lock
|
||||
40
.vscode/settings.json
vendored
Normal file
40
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"cSpell.words": [
|
||||
"AUTHCRAMMD",
|
||||
"AUTHLOGIN",
|
||||
"AUTHPLAIN",
|
||||
"bordercolor",
|
||||
"CRAMMD",
|
||||
"dateparse",
|
||||
"EHLO",
|
||||
"ESMTP",
|
||||
"EXPN",
|
||||
"gofmt",
|
||||
"Healthz",
|
||||
"HTTPIP",
|
||||
"Inlines",
|
||||
"jhillyerd",
|
||||
"leporo",
|
||||
"lithammer",
|
||||
"livez",
|
||||
"Mechs",
|
||||
"navhtml",
|
||||
"neostandard",
|
||||
"nolint",
|
||||
"popperjs",
|
||||
"readyz",
|
||||
"RSET",
|
||||
"shortuuid",
|
||||
"SMTPTLS",
|
||||
"swaggerexpert",
|
||||
"UITLS",
|
||||
"VRFY",
|
||||
"writef"
|
||||
]
|
||||
}
|
||||
1250
CHANGELOG.md
1250
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
22
CONTRIBUTING.md
Normal file
22
CONTRIBUTING.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Contributing to Mailpit
|
||||
|
||||
Thank you for your interest in contributing to Mailpit!
|
||||
|
||||
## Reporting issues and feature requests
|
||||
|
||||
If you find a bug or have a feature request, please [open an issue](https://github.com/axllent/mailpit/issues) and provide as much detail as possible. Please **do not** report security issues here (see below).
|
||||
|
||||
|
||||
## Reporting security issues
|
||||
|
||||
Please do not report security issues publicly in GitHub. Refer to [SECURITY document](https://github.com/axllent/mailpit/blob/develop/.github/SECURITY.md) for instructions and contact information.
|
||||
|
||||
|
||||
## Contributing code
|
||||
|
||||
Please ensure your code is clean and well-commented, and [passes linting](https://mailpit.axllent.org/docs/development/code-linting/) before submitting a Pull Request. Contributions should enhance the functionality or usability of Mailpit, focusing on quality over quantity.
|
||||
|
||||
Note that while assistance from AI tools is perfectly acceptable, **"[vibe coded](https://en.wikipedia.org/wiki/Vibe_coding)" pull requests will most likely not be accepted.**
|
||||
We value the unique insights and creativity that individual contributors bring to the project.
|
||||
|
||||
Thank you for your understanding and for contributing to Mailpit!
|
||||
@@ -79,7 +79,7 @@ sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/i
|
||||
You can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:
|
||||
|
||||
```shell
|
||||
INSTALL_PATH=/usr/bin sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
sudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
|
||||
```
|
||||
|
||||
|
||||
@@ -115,3 +115,10 @@ Please refer to [the documentation](https://mailpit.axllent.org/docs/install/tes
|
||||
Mailpit's SMTP server (default on port 1025), so you will likely need to configure your sending application to deliver mail via that port.
|
||||
A common MTA (Mail Transfer Agent) that delivers system emails to an SMTP server is `sendmail`, used by many applications, including PHP.
|
||||
Mailpit can also act as substitute for sendmail. For instructions on how to set this up, please refer to the [sendmail documentation](https://mailpit.axllent.org/docs/install/sendmail/).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
For team features, multiple inboxes, and a hosted setup, try
|
||||
<a href="https://mailtrap.io/?ref=mailpit">Mailtrap</a>, our friendly companion.
|
||||
</p>
|
||||
|
||||
19
SECURITY.md
19
SECURITY.md
@@ -1,19 +0,0 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
|
||||
|
||||
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
|
||||
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
@@ -17,7 +17,7 @@ The database can either be the database file (eg: --database /var/lib/mailpit/ma
|
||||
URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL
|
||||
should be the base URL of your running Mailpit instance, not the link to the API itself.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if err := dump.Sync(args[0]); err != nil {
|
||||
logger.Log().Fatal(err)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox
|
||||
The --recent flag will only consider files with a modification date within the last X days.`,
|
||||
// Hidden: true,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
var count int
|
||||
var total int
|
||||
var per100start = time.Now()
|
||||
@@ -55,7 +55,7 @@ The --recent flag will only consider files with a modification date within the l
|
||||
logger.Log().Errorf("%s: %s", path, err.Error())
|
||||
return nil
|
||||
}
|
||||
defer f.Close() // #nosec
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ status 1 if unhealthy.
|
||||
If running within Docker, it should automatically detect environment
|
||||
settings to determine the HTTP bind interface & port.
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
|
||||
proto := "http"
|
||||
if useHTTPS {
|
||||
|
||||
@@ -18,7 +18,7 @@ var reindexCmd = &cobra.Command{
|
||||
If you have several thousand messages in your mailbox, then it is advised to shut down
|
||||
Mailpit while you reindex as this process will likely result in database locking issues.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
config.Database = args[0]
|
||||
config.MaxMessages = 0
|
||||
|
||||
|
||||
66
cmd/root.go
66
cmd/root.go
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/prometheus"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
@@ -39,6 +40,14 @@ Documentation:
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start Prometheus metrics if enabled
|
||||
switch prometheus.GetMode() {
|
||||
case "integrated":
|
||||
prometheus.StartUpdater()
|
||||
case "separate":
|
||||
go prometheus.StartSeparateServer()
|
||||
}
|
||||
|
||||
go server.Listen()
|
||||
|
||||
if err := smtpd.Listen(); err != nil {
|
||||
@@ -58,14 +67,6 @@ func Execute() {
|
||||
}
|
||||
}
|
||||
|
||||
// SendmailExecute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func SendmailExecute() {
|
||||
args := []string{"mailpit", "sendmail"}
|
||||
|
||||
rootCmd.Run(sendmailCmd, args)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// hide autocompletion
|
||||
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
||||
@@ -84,6 +85,7 @@ func init() {
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
|
||||
rootCmd.Flags().BoolVar(&config.DisableWAL, "disable-wal", config.DisableWAL, "Disable WAL for local database (allows NFS mounted DBs)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableVersionCheck, "disable-version-check", config.DisableVersionCheck, "Disable version update checking")
|
||||
rootCmd.Flags().IntVar(&config.Compression, "compression", config.Compression, "Compression level to store raw messages (0-3)")
|
||||
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
@@ -101,11 +103,17 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
|
||||
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
|
||||
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)")
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
rootCmd.Flags().BoolVar(&config.AllowInternalHTTPRequests, "allow-internal-http-requests", config.AllowInternalHTTPRequests, "Allow link-checker & screenshots to access internal IP addresses")
|
||||
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
|
||||
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.HideDeleteAllButton, "hide-delete-all-button", config.HideDeleteAllButton, "Hide the \"Delete all\" button in the web UI")
|
||||
|
||||
// Send API
|
||||
rootCmd.Flags().StringVar(&config.SendAPIAuthFile, "send-api-auth-file", config.SendAPIAuthFile, "A password file for Send API authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, "send-api-auth-accept-any", config.SendAPIAuthAcceptAny, "Accept any username and password for the Send API endpoint, including none")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
@@ -119,6 +127,7 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
|
||||
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
|
||||
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
|
||||
rootCmd.Flags().BoolVar(&config.SMTPIgnoreRejectedRecipients, "smtp-ignore-rejected-recipients", config.SMTPIgnoreRejectedRecipients, "Ignore rejected SMTP recipients with 2xx response")
|
||||
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
|
||||
|
||||
// SMTP relay
|
||||
@@ -144,10 +153,15 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
|
||||
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
|
||||
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
|
||||
rootCmd.Flags().BoolVar(&config.TagsUsername, "tags-username", config.TagsUsername, "Auto-tag messages with the authenticated username")
|
||||
|
||||
// Prometheus metrics
|
||||
rootCmd.Flags().StringVar(&config.PrometheusListen, "enable-prometheus", config.PrometheusListen, "Enable Prometheus metrics: true|false|<ip:port> (eg:'0.0.0.0:9090')")
|
||||
|
||||
// Webhook
|
||||
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
|
||||
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
|
||||
rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (default 0)")
|
||||
|
||||
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
|
||||
@@ -186,6 +200,8 @@ func initConfigFromEnv() {
|
||||
|
||||
config.DisableWAL = getEnabledFromEnv("MP_DISABLE_WAL")
|
||||
|
||||
config.DisableVersionCheck = getEnabledFromEnv("MP_DISABLE_VERSION_CHECK")
|
||||
|
||||
if len(os.Getenv("MP_COMPRESSION")) > 0 {
|
||||
config.Compression, _ = strconv.Atoi(os.Getenv("MP_COMPRESSION"))
|
||||
}
|
||||
@@ -235,6 +251,9 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") {
|
||||
config.AllowInternalHTTPRequests = true
|
||||
}
|
||||
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
|
||||
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
|
||||
}
|
||||
@@ -244,6 +263,18 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_DISABLE_HTTP_COMPRESSION") {
|
||||
config.DisableHTTPCompression = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_HIDE_DELETE_ALL_BUTTON") {
|
||||
config.HideDeleteAllButton = true
|
||||
}
|
||||
|
||||
// Send API
|
||||
config.SendAPIAuthFile = os.Getenv("MP_SEND_API_AUTH_FILE")
|
||||
if err := auth.SetSendAPIAuth(os.Getenv("MP_SEND_API_AUTH")); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SEND_API_AUTH_ACCEPT_ANY") {
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
@@ -276,6 +307,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
|
||||
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_IGNORE_REJECTED_RECIPIENTS") {
|
||||
config.SMTPIgnoreRejectedRecipients = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
|
||||
smtpd.DisableReverseDNS = true
|
||||
}
|
||||
@@ -302,6 +336,8 @@ func initConfigFromEnv() {
|
||||
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")
|
||||
config.SMTPRelayConfig.PreserveMessageIDs = getEnabledFromEnv("MP_SMTP_RELAY_PRESERVE_MESSAGE_IDS")
|
||||
config.SMTPRelayConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_RELAY_FWD_SMTP_ERRORS")
|
||||
|
||||
// SMTP forwarding
|
||||
config.SMTPForwardConfigFile = os.Getenv("MP_SMTP_FORWARD_CONFIG")
|
||||
@@ -320,6 +356,7 @@ func initConfigFromEnv() {
|
||||
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")
|
||||
config.SMTPForwardConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_FORWARD_FWD_SMTP_ERRORS")
|
||||
|
||||
// Chaos
|
||||
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
|
||||
@@ -341,6 +378,12 @@ func initConfigFromEnv() {
|
||||
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
|
||||
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
|
||||
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
|
||||
config.TagsUsername = getEnabledFromEnv("MP_TAGS_USERNAME")
|
||||
|
||||
// Prometheus metrics
|
||||
if len(os.Getenv("MP_ENABLE_PROMETHEUS")) > 0 {
|
||||
config.PrometheusListen = os.Getenv("MP_ENABLE_PROMETHEUS")
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {
|
||||
@@ -349,6 +392,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 {
|
||||
webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT"))
|
||||
}
|
||||
if len(os.Getenv("MP_WEBHOOK_DELAY")) > 0 {
|
||||
webhook.Delay, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_DELAY"))
|
||||
}
|
||||
|
||||
// Demo mode
|
||||
config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE")
|
||||
@@ -358,9 +404,9 @@ func initConfigFromEnv() {
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
logger.Log().Warn("ENV MP_DATA_FILE has been deprecated, use MP_DATABASE")
|
||||
config.Database = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -15,29 +14,44 @@ var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Display the current version & update information",
|
||||
Long: `Display the current version & update information (if available).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
updater.AllowPrereleases = true
|
||||
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
update, _ := cmd.Flags().GetBool("update")
|
||||
noReleaseCheck, _ := cmd.Flags().GetBool("no-release-check")
|
||||
|
||||
if update {
|
||||
return updateApp()
|
||||
// Update the application
|
||||
rel, err := config.GHRUConfig.SelfUpdate()
|
||||
if err != nil {
|
||||
fmt.Printf("Error updating: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel.Tag)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s compiled with %s on %s/%s\n",
|
||||
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil && updater.GreaterThan(latest, config.Version) {
|
||||
if !noReleaseCheck {
|
||||
release, err := config.GHRUConfig.Latest()
|
||||
if err != nil {
|
||||
fmt.Printf("Error checking for latest release: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// The latest version is the same version
|
||||
if release.Tag == config.Version {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// A newer release is available
|
||||
fmt.Printf(
|
||||
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
|
||||
latest,
|
||||
release.Tag,
|
||||
os.Args[0],
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,14 +60,6 @@ func init() {
|
||||
|
||||
versionCmd.Flags().
|
||||
BoolP("update", "u", false, "update to latest version")
|
||||
}
|
||||
|
||||
func updateApp() error {
|
||||
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
|
||||
return nil
|
||||
versionCmd.Flags().
|
||||
Bool("no-release-check", false, "do not check online for the latest release version")
|
||||
}
|
||||
|
||||
181
config/config.go
181
config/config.go
@@ -11,14 +11,28 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/ghru/v2"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"github.com/axllent/mailpit/internal/snakeoil"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is the Mailpit version, updated with every release
|
||||
Version = "dev"
|
||||
|
||||
// GHRUConfig is the configuration for the GitHub Release Updater
|
||||
// used to check for updates and self-update
|
||||
GHRUConfig = ghru.Config{
|
||||
Repo: "axllent/mailpit",
|
||||
ArchiveName: "mailpit-{{.OS}}-{{.Arch}}",
|
||||
BinaryName: "mailpit",
|
||||
CurrentVersion: Version,
|
||||
}
|
||||
|
||||
// SMTPListen to listen on <interface>:<port>
|
||||
SMTPListen = "[::]:1025"
|
||||
|
||||
@@ -72,6 +86,12 @@ var (
|
||||
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
|
||||
DisableHTTPCompression bool
|
||||
|
||||
// SendAPIAuthFile for Send API authentication
|
||||
SendAPIAuthFile string
|
||||
|
||||
// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none
|
||||
SendAPIAuthAcceptAny bool
|
||||
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
@@ -107,11 +127,15 @@ var (
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
// AllowInternalHTTPRequests will allow HTTP requests to internal IP addresses (e.g., loopback, private, link-local, or multicast) when set to true.
|
||||
// This policy applies to both link checking and screenshot generation (proxy) features and is disabled by default for security reasons.
|
||||
AllowInternalHTTPRequests = false
|
||||
|
||||
// CLITagsArg is used to map the CLI args
|
||||
CLITagsArg string
|
||||
|
||||
// ValidTagRegexp represents a valid tag
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
|
||||
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.@]){1,100}$`)
|
||||
|
||||
// TagsConfig is a yaml file to pre-load tags
|
||||
TagsConfig string
|
||||
@@ -123,6 +147,9 @@ var (
|
||||
// including x-tags & plus-addresses
|
||||
TagsDisable string
|
||||
|
||||
// TagsUsername enables auto-tagging messages with the authenticated username
|
||||
TagsUsername bool
|
||||
|
||||
// SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server
|
||||
SMTPRelayConfigFile string
|
||||
|
||||
@@ -158,6 +185,9 @@ var (
|
||||
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
|
||||
SMTPAllowedRecipientsRegexp *regexp.Regexp
|
||||
|
||||
// SMTPIgnoreRejectedRecipients if true, will accept emails to rejected recipients with 2xx response but silently drop them
|
||||
SMTPIgnoreRejectedRecipients bool
|
||||
|
||||
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
|
||||
POP3Listen = "[::]:1110"
|
||||
|
||||
@@ -173,6 +203,9 @@ var (
|
||||
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||
EnableSpamAssassin string
|
||||
|
||||
// HideDeleteAllButton hides the delete all button in the web UI
|
||||
HideDeleteAllButton bool
|
||||
|
||||
// WebhookURL for calling
|
||||
WebhookURL string
|
||||
|
||||
@@ -182,14 +215,9 @@ var (
|
||||
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
|
||||
AllowUntrustedTLS bool
|
||||
|
||||
// Version is the default application version, updated on release
|
||||
Version = "dev"
|
||||
|
||||
// Repo on Github for updater
|
||||
Repo = "axllent/mailpit"
|
||||
|
||||
// RepoBinaryName on Github for updater
|
||||
RepoBinaryName = "mailpit"
|
||||
// PrometheusListen address for Prometheus metrics server
|
||||
// Empty = disabled, true= use existing web server, address = separate server
|
||||
PrometheusListen string
|
||||
|
||||
// ChaosTriggers are parsed and set in the chaos module
|
||||
ChaosTriggers string
|
||||
@@ -197,6 +225,9 @@ var (
|
||||
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// DisableVersionCheck disables version checking
|
||||
DisableVersionCheck bool
|
||||
|
||||
// DemoMode disables SMTP relay, link checking & HTTP send functionality
|
||||
DemoMode = false
|
||||
)
|
||||
@@ -224,6 +255,8 @@ type SMTPRelayConfigStruct struct {
|
||||
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
|
||||
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
|
||||
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
|
||||
PreserveMessageIDs bool `yaml:"preserve-message-ids"` // preserve the original Message-ID when relaying
|
||||
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
RecipientAllowlist string `yaml:"recipient-allowlist"`
|
||||
@@ -231,18 +264,19 @@ type SMTPRelayConfigStruct struct {
|
||||
|
||||
// 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
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
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
|
||||
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
|
||||
TLS bool `yaml:"tls"` // whether to use TLS
|
||||
AllowInsecure bool `yaml:"allow-insecure"` // allow insecure authentication, ignore TLS validation
|
||||
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
|
||||
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
|
||||
}
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
@@ -255,7 +289,8 @@ func VerifyConfig() error {
|
||||
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
|
||||
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
|
||||
// See server.middleWareFunc()
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
ContentSecurityPolicy = fmt.Sprintf(
|
||||
"default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
|
||||
@@ -286,6 +321,7 @@ func VerifyConfig() error {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
// Web UI & API
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
@@ -308,8 +344,19 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
if UITLSCert != "" {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
if strings.HasPrefix(UITLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
UITLSCert = snakeoil.Public(UITLSCert)
|
||||
} else {
|
||||
UITLSCert = filepath.Clean(UITLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(UITLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
UITLSKey = snakeoil.Private(UITLSKey)
|
||||
} else {
|
||||
UITLSKey = filepath.Clean(UITLSKey)
|
||||
}
|
||||
|
||||
if !isFile(UITLSCert) {
|
||||
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
|
||||
@@ -320,13 +367,67 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Send API
|
||||
if SendAPIAuthFile != "" {
|
||||
SendAPIAuthFile = filepath.Clean(SendAPIAuthFile)
|
||||
|
||||
if !isFile(SendAPIAuthFile) {
|
||||
return fmt.Errorf("[send-api] password file not found or readable: %s", SendAPIAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SendAPIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetSendAPIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Info("[send-api] enabling basic authentication")
|
||||
}
|
||||
|
||||
if auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {
|
||||
return errors.New("[send-api] authentication cannot use both credentials and --send-api-auth-accept-any")
|
||||
}
|
||||
|
||||
if SendAPIAuthAcceptAny && auth.UICredentials != nil {
|
||||
logger.Log().Info("[send-api] disabling authentication")
|
||||
}
|
||||
|
||||
// Prometheus configuration validation
|
||||
if PrometheusListen != "" {
|
||||
mode := strings.ToLower(strings.TrimSpace(PrometheusListen))
|
||||
if mode != "true" && mode != "false" {
|
||||
// Validate as address for separate server mode
|
||||
_, err := net.ResolveTCPAddr("tcp", PrometheusListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[prometheus] %s", err.Error())
|
||||
}
|
||||
} else if mode == "true" {
|
||||
logger.Log().Info("[prometheus] enabling metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("[smtp] you must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPTLSCert != "" {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
if strings.HasPrefix(SMTPTLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
SMTPTLSCert = snakeoil.Public(SMTPTLSCert)
|
||||
} else {
|
||||
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(SMTPTLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
SMTPTLSKey = snakeoil.Private(SMTPTLSKey)
|
||||
} else {
|
||||
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
|
||||
}
|
||||
|
||||
if !isFile(SMTPTLSCert) {
|
||||
return fmt.Errorf("[smtp] TLS certificate not found or readable: %s", SMTPTLSCert)
|
||||
@@ -394,8 +495,18 @@ func VerifyConfig() error {
|
||||
|
||||
// POP3 server
|
||||
if POP3TLSCert != "" {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
if strings.HasPrefix(POP3TLSCert, "sans:") {
|
||||
// generate a self-signed certificate
|
||||
POP3TLSCert = snakeoil.Public(POP3TLSCert)
|
||||
} else {
|
||||
POP3TLSCert = filepath.Clean(POP3TLSCert)
|
||||
}
|
||||
if strings.HasPrefix(POP3TLSKey, "sans:") {
|
||||
// generate a self-signed key
|
||||
POP3TLSKey = snakeoil.Private(POP3TLSKey)
|
||||
} else {
|
||||
POP3TLSKey = filepath.Clean(POP3TLSKey)
|
||||
}
|
||||
|
||||
if !isFile(POP3TLSCert) {
|
||||
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
|
||||
@@ -480,6 +591,14 @@ func VerifyConfig() error {
|
||||
logger.Log().Infof("[smtp] only allowing recipients matching regexp: %s", SMTPAllowedRecipients)
|
||||
}
|
||||
|
||||
if SMTPIgnoreRejectedRecipients {
|
||||
if SMTPAllowedRecipientsRegexp == nil {
|
||||
logger.Log().Warn("[smtp] ignoring rejected recipients has no effect without setting smtp-allowed-recipients")
|
||||
} else {
|
||||
logger.Log().Info("[smtp] ignoring rejected recipients")
|
||||
}
|
||||
}
|
||||
|
||||
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -503,8 +622,10 @@ func VerifyConfig() error {
|
||||
}
|
||||
|
||||
SMTPRelayMatchingRegexp = re
|
||||
logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
|
||||
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port)
|
||||
logger.Log().Infof(
|
||||
"[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
|
||||
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func isFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import pluginVue from 'esbuild-plugin-vue-next'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import * as esbuild from "esbuild";
|
||||
import pluginVue from "esbuild-plugin-vue-next";
|
||||
import { sassPlugin } from "esbuild-sass-plugin";
|
||||
|
||||
const doWatch = process.env.WATCH == 'true' ? true : false;
|
||||
const doMinify = process.env.MINIFY == 'true' ? true : false;
|
||||
const doWatch = process.env.WATCH === "true";
|
||||
const doMinify = process.env.MINIFY === "true";
|
||||
|
||||
const ctx = await esbuild.context(
|
||||
{
|
||||
entryPoints: [
|
||||
"server/ui-src/app.js",
|
||||
"server/ui-src/docs.js"
|
||||
],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
'__VUE_OPTIONS_API__': 'true',
|
||||
'__VUE_PROD_DEVTOOLS__': 'false',
|
||||
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ['import'],
|
||||
quietDeps: true,
|
||||
})
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info"
|
||||
}
|
||||
)
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["server/ui-src/app.js", "server/ui-src/docs.js"],
|
||||
bundle: true,
|
||||
minify: doMinify,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: "true",
|
||||
__VUE_PROD_DEVTOOLS__: "false",
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
|
||||
},
|
||||
outdir: "server/ui/dist/",
|
||||
plugins: [
|
||||
pluginVue(),
|
||||
sassPlugin({
|
||||
silenceDeprecations: ["import"],
|
||||
quietDeps: true,
|
||||
}),
|
||||
],
|
||||
loader: {
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
if (doWatch) {
|
||||
await ctx.watch()
|
||||
await ctx.watch();
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
ctx.dispose()
|
||||
await ctx.rebuild();
|
||||
ctx.dispose();
|
||||
}
|
||||
|
||||
76
eslint.config.js
Normal file
76
eslint.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
import globals from "globals";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import js from "@eslint/js";
|
||||
import vue from "eslint-plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url));
|
||||
|
||||
export default [
|
||||
/* Use .gitignore to prevent linting of irrelevant files */
|
||||
includeIgnoreFile(gitignorePath, ".gitignore"),
|
||||
|
||||
/* ESLint's recommended rules */
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: js.configs.recommended.rules,
|
||||
},
|
||||
|
||||
/* Vue-specific rules */
|
||||
...vue.configs["flat/recommended"],
|
||||
|
||||
/* Prettier is responsible for formatting, so we disable conflicting rules */
|
||||
eslintConfigPrettier,
|
||||
|
||||
/* Our custom rules */
|
||||
{
|
||||
rules: {
|
||||
/* Always use arrow functions for tidiness and consistency */
|
||||
"prefer-arrow-callback": "error",
|
||||
|
||||
/* Always use camelCase for variable names */
|
||||
camelcase: [
|
||||
"error",
|
||||
{
|
||||
ignoreDestructuring: false,
|
||||
ignoreGlobals: true,
|
||||
ignoreImports: false,
|
||||
properties: "never",
|
||||
},
|
||||
],
|
||||
|
||||
/* The default case in switch statements must always be last */
|
||||
"default-case-last": "error",
|
||||
|
||||
/* Always use dot notation where possible (e.g. `obj.val` over `obj['val']`) */
|
||||
"dot-notation": "error",
|
||||
|
||||
/* Always use `===` and `!==` for comparisons unless unambiguous */
|
||||
eqeqeq: ["error", "smart"],
|
||||
|
||||
/* Never use `eval()` as it violates our CSP and can lead to security issues */
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
|
||||
/* Prevents accidental use of template literals in plain strings, e.g. "my ${var}" */
|
||||
"no-template-curly-in-string": "error",
|
||||
|
||||
/* Avoid unnecessary ternary operators */
|
||||
"no-unneeded-ternary": "error",
|
||||
|
||||
/* Avoid unused expressions that have no purpose */
|
||||
"no-unused-expressions": "error",
|
||||
|
||||
/* Always use `const` or `let` to make scope behaviour clear */
|
||||
"no-var": "error",
|
||||
|
||||
/* Always use shorthand syntax for objects where possible, e.g. { a, b() { } } */
|
||||
"object-shorthand": "error",
|
||||
|
||||
/* Always use `const` for variables that are never reassigned */
|
||||
"prefer-const": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
84
go.mod
84
go.mod
@@ -1,63 +1,81 @@
|
||||
module github.com/axllent/mailpit
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
github.com/axllent/ghru/v2 v2.1.0
|
||||
github.com/axllent/semver v1.0.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.3.0
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/kovidgoyal/imaging v1.6.4
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/leporo/sqlf v1.4.0
|
||||
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.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/tg123/go-htpasswd v1.2.4
|
||||
github.com/vanng822/go-premailer v1.24.0
|
||||
golang.org/x/net v0.39.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.37.0
|
||||
github.com/vanng822/go-premailer v1.31.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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/inbucket/html2text v1.0.0 // 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
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.7 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.3 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/reiver/go-oi v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/libc v1.65.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
212
go.sum
212
go.sum
@@ -1,15 +1,25 @@
|
||||
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.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
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=
|
||||
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
|
||||
github.com/axllent/ghru/v2 v2.1.0 h1:zNW96KO+rmXggizZhHzIX7MExOiV4jx+63Y9nXlwLV0=
|
||||
github.com/axllent/ghru/v2 v2.1.0/go.mod h1:8l7s1phdc375vvf8LHxT7wnJqXlThdHJR5EBtHNWhTg=
|
||||
github.com/axllent/semver v1.0.0 h1:FDekA0alnMed5bWVWjUwBS+6QouZZkmPXsGVmOfjWOg=
|
||||
github.com/axllent/semver v1.0.0/go.mod h1:ySHHYLyFX3vKAALmaO8TOOJkzGRsUNmzFiIWwPm8li8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -17,13 +27,21 @@ 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/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -34,65 +52,87 @@ 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/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs=
|
||||
github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU=
|
||||
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.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
|
||||
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU=
|
||||
github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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/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=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=
|
||||
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
|
||||
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.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-20250128004930-114c7828b55a h1:9O8zgGrMBuTsnA3yyFd+JWhFSflQwzSUEB4AMnFHKhU=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 h1:BoxiqWvhprOB2isgM59s8wkgKwAoyQH66Twfmof41oE=
|
||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/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=
|
||||
@@ -102,36 +142,41 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
|
||||
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
|
||||
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.24.0 h1:b4MpHLVdlA7QOwk5OJIEvWnIpCCdEhEDQpJ/AkEYcpo=
|
||||
github.com/vanng822/go-premailer v1.24.0/go.mod h1:gjLku4P5inmyu+MM7544lOjhaW8F3TdIqboFVcZGwZE=
|
||||
github.com/vanng822/go-premailer v1.31.0 h1:r1a1WH2I5NnGMhrmjVZyYhY0ThvaamKBkS2UuM91Fuo=
|
||||
github.com/vanng822/go-premailer v1.31.0/go.mod h1:hzI26/YvzUADrxqifxGLJvNvn3tWBU6VMHRvxsskpuo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
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.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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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=
|
||||
@@ -141,8 +186,8 @@ 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.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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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=
|
||||
@@ -150,13 +195,12 @@ 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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -165,8 +209,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -185,47 +229,51 @@ 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.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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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.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.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
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=
|
||||
|
||||
@@ -15,7 +15,7 @@ done
|
||||
OS=
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="Darwin" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*)
|
||||
echo "OS not supported."
|
||||
exit 2
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
var (
|
||||
// UICredentials passwords
|
||||
UICredentials *htpasswd.File
|
||||
// SendAPICredentials passwords
|
||||
SendAPICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
// POP3Credentials passwords
|
||||
@@ -36,6 +38,25 @@ func SetUIAuth(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSendAPIAuth will set Send API credentials
|
||||
func SetSendAPIAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSMTPAuth will set SMTP credentials
|
||||
func SetSMTPAuth(s string) error {
|
||||
var err error
|
||||
|
||||
@@ -39,14 +39,14 @@ func Sync(d string) error {
|
||||
|
||||
if URL != "" {
|
||||
if !linkRe.MatchString(URL) {
|
||||
return errors.New("Invalid URL")
|
||||
return errors.New("invalid URL")
|
||||
}
|
||||
|
||||
base = strings.TrimRight(URL, "/") + "/"
|
||||
}
|
||||
|
||||
if base == "" && config.Database == "" {
|
||||
return errors.New("No database or API URL specified")
|
||||
return errors.New("no database or API URL specified")
|
||||
}
|
||||
|
||||
if !tools.IsDir(outDir) {
|
||||
@@ -109,7 +109,7 @@ func loadIDs() error {
|
||||
}
|
||||
|
||||
if len(summary) == 0 {
|
||||
return errors.New("No messages found")
|
||||
return errors.New("no messages found")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -141,19 +144,20 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
attributes := link.Attr
|
||||
for _, a := range attributes {
|
||||
if a.Key == "href" {
|
||||
if !isURL(a.Val) {
|
||||
// skip invalid URL
|
||||
continue
|
||||
}
|
||||
|
||||
if config.BlockRemoteCSSAndFonts {
|
||||
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if !isValidURL(a.Val) {
|
||||
// skip invalid URL
|
||||
logger.Log().Warnf("[html-check] ignoring unsupported stylesheet URL: %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := downloadCSSToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
|
||||
logger.Log().Warnf("[html-check] %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -182,25 +186,41 @@ func inlineRemoteCSS(h string) (string, error) {
|
||||
return newDoc, nil
|
||||
}
|
||||
|
||||
// DownloadToBytes returns a []byte slice from a URL
|
||||
func downloadToBytes(url string) ([]byte, error) {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Get(url)
|
||||
// DownloadCSSToBytes returns a []byte slice from a URL.
|
||||
// It requires the HTTP response code to be 200 and the content-type to be text/css.
|
||||
// It will download a maximum of 5MB.
|
||||
func downloadCSSToBytes(url string) ([]byte, error) {
|
||||
client := newSafeHTTPClient()
|
||||
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit HTML Checker/"+config.Version)
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
err := fmt.Errorf("error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
ct := strings.ToLower(resp.Header.Get("content-type"))
|
||||
if !strings.Contains(ct, "text/css") {
|
||||
err := fmt.Errorf("invalid CSS content-type from %s: \"%s\" (expected \"text/css\")", url, ct)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set a limit on the number of bytes to read - max 5MB
|
||||
limit := int64(5242880)
|
||||
limitedReader := &io.LimitedReader{R: resp.Body, N: limit}
|
||||
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,10 +228,12 @@ func downloadToBytes(url string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
// Test if the string is a supported URL.
|
||||
// The URL must have the "http" or "https" scheme, and must not contain any login info (http://user:pass@<host>).
|
||||
func isValidURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && u.User.String() == ""
|
||||
}
|
||||
|
||||
// Test the HTML for inline CSS styles and styling attributes
|
||||
@@ -249,3 +271,40 @@ func testInlineStyles(doc *goquery.Document) map[string]int {
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func newSafeHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: nil, // avoid env proxy surprises unless you explicitly want it
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
MaxIdleConns: 50,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// re-validate every redirect hop.
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
if !isValidURL(req.URL.String()) {
|
||||
return errors.New("invalid redirect URL")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -152,19 +152,20 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
switch support {
|
||||
case "y":
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
case "n":
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
default:
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
noteIDs := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
for _, id := range noteIDs {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,14 @@ var (
|
||||
</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://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
|
||||
}
|
||||
@@ -41,10 +48,18 @@ var (
|
||||
[http://localhost]
|
||||
www.google.com < ignored
|
||||
|||http://example.com/?some=query-string|||
|
||||
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
|
||||
// recognize potential spaces in between the URL
|
||||
<https://example.com/ link with spaces>
|
||||
`
|
||||
|
||||
expectedTextLinks = []string{
|
||||
"http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string",
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"http://localhost",
|
||||
"http://example.com/?some=query-string",
|
||||
"https://example.com/ link with spaces",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -30,9 +30,28 @@ func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
|
||||
}
|
||||
|
||||
func extractTextLinks(msg *storage.Message) []string {
|
||||
testLinkRe := regexp.MustCompile(`(?im)([^<]\b)((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+))`)
|
||||
// RFC2396 appendix E states angle brackets are recommended for text/plain emails to
|
||||
// recognize potential spaces in between the URL
|
||||
// @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E
|
||||
bracketLinkRe := regexp.MustCompile(`(?im)<((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;][^>]+))>`)
|
||||
|
||||
links := []string{}
|
||||
|
||||
links = append(links, linkRe.FindAllString(msg.Text, -1)...)
|
||||
matches := testLinkRe.FindAllStringSubmatch(msg.Text, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) > 0 {
|
||||
links = append(links, match[2])
|
||||
}
|
||||
}
|
||||
|
||||
angleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, -1)
|
||||
for _, match := range angleMatches {
|
||||
if len(match) > 0 {
|
||||
link := strings.ReplaceAll(match[1], "\n", "")
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package linkcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
@@ -34,6 +40,10 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
if err != nil {
|
||||
l.StatusCode = 0
|
||||
l.Status = httpErrorSummary(err)
|
||||
if strings.Contains(l.Status, "private/reserved address") {
|
||||
l.Status = "Blocked private/reserved address"
|
||||
l.StatusCode = 451
|
||||
}
|
||||
} else {
|
||||
l.StatusCode = code
|
||||
l.Status = http.StatusText(code)
|
||||
@@ -57,23 +67,37 @@ func getHTTPStatuses(links []string, followRedirects bool) []Link {
|
||||
|
||||
// Do a HEAD request to return HTTP status code
|
||||
func doHead(link string, followRedirects bool) (int, error) {
|
||||
if !tools.IsValidLinkURL(link) {
|
||||
return 0, fmt.Errorf("invalid URL: %s", link)
|
||||
}
|
||||
|
||||
timeout := time.Duration(10 * time.Second)
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
tr := &http.Transport{}
|
||||
tr := &http.Transport{
|
||||
DialContext: safeDialContext(dialer),
|
||||
}
|
||||
|
||||
if config.AllowUntrustedTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if followRedirects {
|
||||
return nil
|
||||
if len(via) >= 3 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
if !followRedirects {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if !tools.IsValidLinkURL(req.URL.String()) {
|
||||
return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,7 +116,6 @@ func doHead(link string, followRedirects bool) (int, error) {
|
||||
}
|
||||
|
||||
return 0, err
|
||||
|
||||
}
|
||||
|
||||
return res.StatusCode, nil
|
||||
@@ -107,8 +130,33 @@ func httpErrorSummary(err error) string {
|
||||
if !re.MatchString(e) {
|
||||
return e
|
||||
}
|
||||
|
||||
parts := re.FindAllStringSubmatch(e, -1)
|
||||
|
||||
return parts[0][len(parts[0])-1]
|
||||
}
|
||||
|
||||
// SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection.
|
||||
func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !config.AllowInternalHTTPRequests {
|
||||
for _, ip := range ips {
|
||||
if tools.IsInternalIP(ip.IP) {
|
||||
logger.Log().Warnf("[link-check] Blocked HEAD request to private/reserved address: %s (%s)", host, ip)
|
||||
return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +66,6 @@ func PrettyPrint(i interface{}) {
|
||||
fmt.Println(string(s))
|
||||
}
|
||||
|
||||
// CleanIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
|
||||
func CleanIP(s string) string {
|
||||
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
|
||||
if re.MatchString(s) {
|
||||
return "0.0.0.0:" + s[5:]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// CleanHTTPIP returns a human-readable IP for the logging interface
|
||||
// when starting services. It translates [::]:<port> to "localhost:<port>"
|
||||
func CleanHTTPIP(s string) string {
|
||||
|
||||
@@ -18,7 +18,7 @@ func authUser(username, password string) bool {
|
||||
|
||||
// Send a response with debug logging
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
_, _ = fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
|
||||
if strings.HasPrefix(m, "-ERR ") {
|
||||
@@ -29,7 +29,7 @@ func sendResponse(c net.Conn, m string) {
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
_, _ = fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
// Get the latest 100 messages
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/pop3client"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,22 +29,21 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// connect with bad password
|
||||
t.Log("Testing invalid login")
|
||||
c, err := connectBadAuth()
|
||||
if err == nil {
|
||||
if _, err := connectBadAuth(); err == nil {
|
||||
t.Error("invalid login gained access")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Testing valid login")
|
||||
c, err = connectAuth()
|
||||
c, err := connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, size, err := c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// quit else we get old data
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,13 +62,13 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,29 +79,69 @@ func TestPOP3(t *testing.T) {
|
||||
for i := 1; i <= 20; i++ {
|
||||
_, err := c.Retr(i)
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL with multiple arguments")
|
||||
|
||||
_, err = c.Cmd("UIDL", false, 1, 2, 3)
|
||||
if err == nil {
|
||||
t.Error("UIDL with multiple arguments should return an error")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL without a message id")
|
||||
|
||||
messageIDs, err := c.Uidl(0)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(messageIDs) != 50 {
|
||||
assertEqual(t, len(messageIDs), 50, "incorrect UIDL message count")
|
||||
}
|
||||
|
||||
t.Log("Checking UIDL with a message ID")
|
||||
|
||||
messageIDs, err = c.Uidl(50)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, len(messageIDs), 1, "incorrect UIDL message count")
|
||||
|
||||
t.Log("Checking UIDL with an invalid message ID")
|
||||
|
||||
if _, err := c.Uidl(51); err == nil {
|
||||
t.Errorf("UIDL 51 should return an error")
|
||||
return
|
||||
}
|
||||
|
||||
t.Log("Deleting 25 messages")
|
||||
|
||||
for i := 1; i <= 25; i++ {
|
||||
if err := c.Dele(i); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// messages get deleted after a QUIT
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// allow for background delete when using rqlite driver
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +149,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,13 +157,13 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
// messages get deleted after a QUIT
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +171,7 @@ func TestPOP3(t *testing.T) {
|
||||
|
||||
for i := 1; i <= 25; i++ {
|
||||
if err := c.Dele(i); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -140,31 +179,31 @@ func TestPOP3(t *testing.T) {
|
||||
t.Log("Undeleting messages")
|
||||
|
||||
if err := c.Rset(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count, _, err = c.Stat()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assertEqual(t, count, 25, "incorrect message count")
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -187,7 +226,7 @@ func TestAuthentication(t *testing.T) {
|
||||
// non-authenticated connection
|
||||
c, err := connect()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,7 +243,7 @@ func TestAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,7 +252,7 @@ func TestAuthentication(t *testing.T) {
|
||||
// authenticated connection
|
||||
c, err = connectAuth()
|
||||
if err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,13 +269,15 @@ func TestAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := c.Quit(); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setup() {
|
||||
auth.SetPOP3Auth("username:password")
|
||||
if err := auth.SetPOP3Auth("username:password"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
config.Database = os.Getenv("MP_DATABASE")
|
||||
@@ -343,7 +384,7 @@ func insertEmailData(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
id, err := storage.Store(&bufBytes)
|
||||
id, err := storage.Store(&bufBytes, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -80,7 +80,7 @@ func Run() {
|
||||
|
||||
type message struct {
|
||||
ID string
|
||||
Size float64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn) {
|
||||
@@ -211,28 +211,59 @@ func handleClient(conn net.Conn) {
|
||||
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
|
||||
switch cmd {
|
||||
case "STAT":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize))
|
||||
case "LIST":
|
||||
totalSize := float64(0)
|
||||
totalSize := uint64(0)
|
||||
for _, m := range messages {
|
||||
totalSize += m.Size
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing
|
||||
if len(args) > 0 {
|
||||
arg, _ := getSafeArg(args, 0)
|
||||
nr, err := strconv.Atoi(arg)
|
||||
if err != nil || nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", nr, messages[nr-1].Size))
|
||||
} else {
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize))
|
||||
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", row+1, m.Size))
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
case "UIDL":
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
|
||||
if len(args) > 1 {
|
||||
sendResponse(conn, "-ERR UIDL takes at most one argument")
|
||||
} else if len(args) == 1 {
|
||||
nr, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
if nr < 1 || nr > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
|
||||
m := messages[nr-1]
|
||||
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %s", nr, m.ID))
|
||||
} else {
|
||||
sendResponse(conn, "+OK unique-id listing follows")
|
||||
for row, m := range messages {
|
||||
sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID))
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
|
||||
case "RETR":
|
||||
if len(args) != 1 {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
@@ -260,7 +291,7 @@ func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages
|
||||
// begins with the termination octet, the line is "byte-stuffed" by
|
||||
// pre-pending the termination octet to that line of the response.
|
||||
// @see: https://www.ietf.org/rfc/rfc1939.txt
|
||||
sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1))
|
||||
sendData(conn, strings.ReplaceAll(string(raw), "\n.", "\n.."))
|
||||
sendResponse(conn, ".")
|
||||
case "TOP":
|
||||
arg, err := getSafeArg(args, 0)
|
||||
|
||||
@@ -422,7 +422,7 @@ func (c *Conn) Noop() error {
|
||||
// Message deletions (DELE command) are only executed by the server on a graceful
|
||||
// quit and close.
|
||||
func (c *Conn) Quit() error {
|
||||
defer c.conn.Close()
|
||||
defer func() { _ = c.conn.Close() }()
|
||||
|
||||
if _, err := c.Cmd("QUIT", false); err != nil {
|
||||
return err
|
||||
|
||||
191
internal/prometheus/metrics.go
Normal file
191
internal/prometheus/metrics.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Package prometheus provides Prometheus metrics for Mailpit
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
// Registry is the Prometheus registry for Mailpit metrics
|
||||
Registry = prometheus.NewRegistry()
|
||||
|
||||
// Metrics
|
||||
totalMessages prometheus.Gauge
|
||||
unreadMessages prometheus.Gauge
|
||||
databaseSize prometheus.Gauge
|
||||
messagesDeleted prometheus.Counter
|
||||
smtpAccepted prometheus.Counter
|
||||
smtpRejected prometheus.Counter
|
||||
smtpIgnored prometheus.Counter
|
||||
smtpAcceptedSize prometheus.Counter
|
||||
uptime prometheus.Gauge
|
||||
memoryUsage prometheus.Gauge
|
||||
tagCounters *prometheus.GaugeVec
|
||||
)
|
||||
|
||||
// InitMetrics initializes all Prometheus metrics
|
||||
func initMetrics() {
|
||||
// Create metrics
|
||||
totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages",
|
||||
Help: "Total number of messages in the database",
|
||||
})
|
||||
|
||||
unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_messages_unread",
|
||||
Help: "Number of unread messages in the database",
|
||||
})
|
||||
|
||||
databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_database_size_bytes",
|
||||
Help: "Size of the database in bytes",
|
||||
})
|
||||
|
||||
messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_messages_deleted_total",
|
||||
Help: "Total number of messages deleted",
|
||||
})
|
||||
|
||||
smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_total",
|
||||
Help: "Total number of SMTP messages accepted",
|
||||
})
|
||||
|
||||
smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_rejected_total",
|
||||
Help: "Total number of SMTP messages rejected",
|
||||
})
|
||||
|
||||
smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_ignored_total",
|
||||
Help: "Total number of SMTP messages ignored (duplicates)",
|
||||
})
|
||||
|
||||
smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "mailpit_smtp_accepted_size_bytes_total",
|
||||
Help: "Total size of accepted SMTP messages in bytes",
|
||||
})
|
||||
|
||||
uptime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_uptime_seconds",
|
||||
Help: "Uptime of Mailpit in seconds",
|
||||
})
|
||||
|
||||
memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "mailpit_memory_usage_bytes",
|
||||
Help: "Memory usage in bytes",
|
||||
})
|
||||
|
||||
tagCounters = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "mailpit_tag_messages",
|
||||
Help: "Number of messages per tag",
|
||||
},
|
||||
[]string{"tag"},
|
||||
)
|
||||
|
||||
// Register metrics
|
||||
Registry.MustRegister(totalMessages)
|
||||
Registry.MustRegister(unreadMessages)
|
||||
Registry.MustRegister(databaseSize)
|
||||
Registry.MustRegister(messagesDeleted)
|
||||
Registry.MustRegister(smtpAccepted)
|
||||
Registry.MustRegister(smtpRejected)
|
||||
Registry.MustRegister(smtpIgnored)
|
||||
Registry.MustRegister(smtpAcceptedSize)
|
||||
Registry.MustRegister(uptime)
|
||||
Registry.MustRegister(memoryUsage)
|
||||
Registry.MustRegister(tagCounters)
|
||||
}
|
||||
|
||||
// UpdateMetrics updates all metrics with current values
|
||||
func updateMetrics() {
|
||||
info := stats.Load(false)
|
||||
|
||||
totalMessages.Set(float64(info.Messages))
|
||||
unreadMessages.Set(float64(info.Unread))
|
||||
databaseSize.Set(float64(info.DatabaseSize))
|
||||
messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted))
|
||||
smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted))
|
||||
smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected))
|
||||
smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored))
|
||||
smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize))
|
||||
uptime.Set(float64(info.RuntimeStats.Uptime))
|
||||
memoryUsage.Set(float64(info.RuntimeStats.Memory))
|
||||
|
||||
// Reset tag counters
|
||||
tagCounters.Reset()
|
||||
|
||||
// Update tag counters
|
||||
for tag, count := range info.Tags {
|
||||
tagCounters.WithLabelValues(tag).Set(float64(count))
|
||||
}
|
||||
}
|
||||
|
||||
// GetHandler returns the Prometheus handler & disables double compression in middleware
|
||||
func GetHandler() http.Handler {
|
||||
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
})
|
||||
}
|
||||
|
||||
// StartUpdater starts the periodic metrics update routine
|
||||
func StartUpdater() {
|
||||
initMetrics()
|
||||
updateMetrics()
|
||||
|
||||
// Start periodic updates
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
updateMetrics()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartSeparateServer starts a separate HTTP server for Prometheus metrics
|
||||
func StartSeparateServer() {
|
||||
StartUpdater()
|
||||
|
||||
logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen)
|
||||
|
||||
// Create a dedicated mux for the metrics server
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
|
||||
|
||||
// Create a dedicated server instance
|
||||
server := &http.Server{
|
||||
Addr: config.PrometheusListen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// GetMode returns the Prometheus run mode
|
||||
func GetMode() string {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen))
|
||||
|
||||
switch mode {
|
||||
case "false", "":
|
||||
return "disabled"
|
||||
case "true":
|
||||
return "integrated"
|
||||
default:
|
||||
return "separate"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ var (
|
||||
)
|
||||
|
||||
// Triggers for the Chaos configuration
|
||||
// swagger:model Triggers
|
||||
//
|
||||
// swagger:model ChaosTriggers
|
||||
type Triggers struct {
|
||||
// Sender trigger to fail on From, Sender
|
||||
Sender Trigger
|
||||
@@ -36,7 +37,8 @@ type Triggers struct {
|
||||
}
|
||||
|
||||
// Trigger for Chaos
|
||||
// swagger:model Trigger
|
||||
//
|
||||
// swagger:model ChaosTrigger
|
||||
type Trigger struct {
|
||||
// SMTP error code to return. The value must range from 400 to 599.
|
||||
// required: true
|
||||
|
||||
@@ -4,25 +4,31 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Wrapper to forward messages if configured
|
||||
func autoForwardMessage(from string, data *[]byte) {
|
||||
func autoForwardMessage(from string, data *[]byte) error {
|
||||
if config.SMTPForwardConfig.Host == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
return errors.WithMessage(err, "[forward] error: %s")
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
"[forward] message from %s to %s via %s:%d",
|
||||
from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) {
|
||||
@@ -37,7 +43,7 @@ func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr stri
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,12 +56,19 @@ func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr stri
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
// Set the hostname for HELO/EHLO
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if err := client.Hello(hostname); err != nil {
|
||||
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
client.Close()
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +85,7 @@ func forward(from string, msg []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
auth := forwardAuthFromConfig()
|
||||
|
||||
@@ -100,6 +113,9 @@ func forward(from string, msg []byte) error {
|
||||
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())
|
||||
if config.SMTPForwardConfig.ForwardSMTPErrors {
|
||||
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,12 +29,12 @@ var (
|
||||
)
|
||||
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data)
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
return SaveToDatabase(origin, from, to, data, smtpUser)
|
||||
}
|
||||
|
||||
// SaveToDatabase will attempt to save a message to the database
|
||||
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (string, error) {
|
||||
func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) {
|
||||
if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) {
|
||||
// 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
|
||||
@@ -73,10 +74,36 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
}
|
||||
|
||||
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
|
||||
autoRelayMessage(from, to, &data)
|
||||
if relayErr := autoRelayMessage(from, to, &data); relayErr != nil {
|
||||
logger.Log().Error(relayErr.Error())
|
||||
|
||||
if config.SMTPRelayConfig.ForwardSMTPErrors {
|
||||
for {
|
||||
unwrappedErr := errors.Unwrap(relayErr)
|
||||
if unwrappedErr == nil {
|
||||
break
|
||||
}
|
||||
relayErr = unwrappedErr
|
||||
}
|
||||
return "", relayErr
|
||||
}
|
||||
}
|
||||
|
||||
// if enabled, this will forward a copy to preconfigured addresses
|
||||
autoForwardMessage(from, &data)
|
||||
if forwardErr := autoForwardMessage(from, &data); forwardErr != nil {
|
||||
logger.Log().Error(forwardErr.Error())
|
||||
|
||||
if config.SMTPForwardConfig.ForwardSMTPErrors {
|
||||
for {
|
||||
unwrappedErr := errors.Unwrap(forwardErr)
|
||||
if unwrappedErr == nil {
|
||||
break
|
||||
}
|
||||
forwardErr = unwrappedErr
|
||||
}
|
||||
return "", forwardErr
|
||||
}
|
||||
}
|
||||
|
||||
// build array of all addresses in the header to compare to the []to array
|
||||
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
|
||||
@@ -110,7 +137,7 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str
|
||||
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
|
||||
}
|
||||
|
||||
id, err := storage.Store(&data)
|
||||
id, err := storage.Store(&data, smtpUser)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] error storing message: %s", err.Error())
|
||||
return "", err
|
||||
@@ -194,15 +221,16 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
|
||||
Debug = true // to enable Mailpit logging
|
||||
srv := &Server{
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
AppName: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
HandlerRcpt: handlerRcpt,
|
||||
AppName: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
IgnoreRejectedRecipients: config.SMTPIgnoreRejectedRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
LogRead: func(remoteIP, verb, line string) {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
},
|
||||
@@ -224,15 +252,27 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
|
||||
}
|
||||
|
||||
if config.SMTPAuthAllowInsecure {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthMechs = map[string]bool{
|
||||
"CRAM-MD5": false,
|
||||
"PLAIN": true,
|
||||
"LOGIN": true,
|
||||
}
|
||||
}
|
||||
|
||||
if auth.SMTPCredentials != nil {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthMechs = map[string]bool{
|
||||
"CRAM-MD5": false,
|
||||
"PLAIN": true,
|
||||
"LOGIN": true,
|
||||
}
|
||||
srv.AuthHandler = authHandler
|
||||
srv.AuthRequired = true
|
||||
} else if config.SMTPAuthAcceptAny {
|
||||
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
|
||||
srv.AuthMechs = map[string]bool{
|
||||
"CRAM-MD5": false,
|
||||
"PLAIN": true,
|
||||
"LOGIN": true,
|
||||
}
|
||||
srv.AuthHandler = authHandlerAny
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,19 @@ package smtpd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Wrapper to auto relay messages if configured
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
func autoRelayMessage(from string, to []string, data *[]byte) error {
|
||||
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
|
||||
filteredTo := []string{}
|
||||
for _, address := range to {
|
||||
@@ -28,16 +29,18 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
return errors.WithMessage(err, "[relay] error")
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -47,16 +50,20 @@ func autoRelayMessage(from string, to []string, data *[]byte) {
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
return errors.WithMessage(err, "[relay] error")
|
||||
}
|
||||
|
||||
logger.Log().Debugf(
|
||||
"[relay] auto-relay message to %s from %s via %s:%d",
|
||||
strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) {
|
||||
@@ -71,7 +78,7 @@ func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*s
|
||||
|
||||
client, err := smtp.NewClient(conn, tlsConf.ServerName)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("SMTP client error: %v", err)
|
||||
}
|
||||
|
||||
@@ -84,12 +91,19 @@ func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*s
|
||||
return nil, fmt.Errorf("error connecting to %s: %v", addr, err)
|
||||
}
|
||||
|
||||
// Set the hostname for HELO/EHLO
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if err := client.Hello(hostname); err != nil {
|
||||
return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.STARTTLS {
|
||||
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
|
||||
tlsConf.InsecureSkipVerify = config.AllowInsecure
|
||||
|
||||
if err = client.StartTLS(tlsConf); err != nil {
|
||||
client.Close()
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +120,7 @@ func Relay(from string, to []string, msg []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
auth := relayAuthFromConfig()
|
||||
|
||||
@@ -126,26 +140,29 @@ func Relay(from string, to []string, msg []byte) error {
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("error response to MAIL command: %s", err.Error())
|
||||
return errors.WithMessage(err, "error sending MAIL command")
|
||||
}
|
||||
|
||||
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())
|
||||
if config.SMTPRelayConfig.ForwardSMTPErrors {
|
||||
return errors.WithMessagef(err, "error response to RCPT command for %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error response to DATA command: %s", err.Error())
|
||||
return errors.WithMessage(err, "error response to DATA command")
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("error sending message: %s", err.Error())
|
||||
return errors.WithMessage(err, "error sending message")
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("error closing connection: %s", err.Error())
|
||||
return errors.WithMessage(err, "error closing connection")
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
@@ -178,7 +195,10 @@ type loginAuth struct {
|
||||
|
||||
// LoginAuth authentication
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
return &loginAuth{
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
@@ -193,7 +213,7 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("Unknown fromServer")
|
||||
return nil, errors.New("unknown fromServer")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -42,7 +43,7 @@ type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) er
|
||||
|
||||
// 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)
|
||||
type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error)
|
||||
|
||||
// HandlerRcpt function called on RCPT. Return accept status.
|
||||
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
|
||||
@@ -92,26 +93,27 @@ 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
|
||||
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
|
||||
IgnoreRejectedRecipients bool // Accept emails to rejected recipients with 2xx response but silently drop them
|
||||
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
|
||||
@@ -217,7 +219,7 @@ func (srv *Server) Serve(ln net.Listener) error {
|
||||
return ErrServerClosed
|
||||
}
|
||||
|
||||
defer ln.Close()
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
for {
|
||||
// if we are shutting down, don't accept new connections
|
||||
@@ -229,7 +231,7 @@ func (srv *Server) Serve(ln net.Listener) error {
|
||||
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
@@ -255,6 +257,7 @@ type session struct {
|
||||
xClientTrust bool // Trust XCLIENT from current IP address
|
||||
tls bool
|
||||
authenticated bool
|
||||
username *string // username, nil if not authenticated
|
||||
}
|
||||
|
||||
// Create new session from connection.
|
||||
@@ -355,11 +358,15 @@ func (srv *Server) Shutdown(ctx context.Context) error {
|
||||
// Function called to handle connection requests.
|
||||
func (s *session) serve() {
|
||||
defer atomic.AddInt32(&s.srv.openSessions, -1)
|
||||
defer s.conn.Close()
|
||||
// pass the connection into the defer function to ensure it is closed,
|
||||
// otherwise results in a 5s timeout for each connection
|
||||
defer func(c net.Conn) { _ = c.Close() }(s.conn)
|
||||
|
||||
var gotEHLO bool
|
||||
var from string
|
||||
var gotFrom bool
|
||||
var gotFROM bool
|
||||
var to []string
|
||||
var hasRejectedRecipients bool
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// RFC 5321 specifies support for minimum of 100 recipients is required.
|
||||
@@ -391,18 +398,22 @@ loop:
|
||||
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.
|
||||
gotEHLO = true
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
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.
|
||||
gotEHLO = true
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "MAIL":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -413,10 +424,22 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotEHLO {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)")
|
||||
break
|
||||
}
|
||||
if to != nil {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)")
|
||||
break
|
||||
}
|
||||
|
||||
match := mailFromRE.FindStringSubmatch(args)
|
||||
match, err := extractAndValidateAddress(mailFromRE, args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
if err != nil {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
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 {
|
||||
@@ -430,7 +453,7 @@ loop:
|
||||
if sizeMatch == nil {
|
||||
// ignore other parameter
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
} else {
|
||||
// Enforce the maximum message size if one is set.
|
||||
@@ -442,18 +465,19 @@ loop:
|
||||
s.writef("%s", err.Error())
|
||||
} else { // SIZE ok
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
} else { // No parameters after FROM
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
gotFROM = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "RCPT":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -464,14 +488,18 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom {
|
||||
if !gotFROM {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
|
||||
break
|
||||
}
|
||||
|
||||
match := rcptToRE.FindStringSubmatch(args)
|
||||
match, err := extractAndValidateAddress(rcptToRE, args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
if err != nil {
|
||||
s.writef("%s", err.Error())
|
||||
} else {
|
||||
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 {
|
||||
@@ -489,6 +517,9 @@ loop:
|
||||
if accept {
|
||||
to = append(to, match[1])
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else if s.srv.IgnoreRejectedRecipients {
|
||||
hasRejectedRecipients = true
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else {
|
||||
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
|
||||
}
|
||||
@@ -503,7 +534,8 @@ loop:
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom || len(to) == 0 {
|
||||
hasRecipients := len(to) > 0 || hasRejectedRecipients
|
||||
if !gotFROM || !hasRecipients {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
|
||||
break
|
||||
}
|
||||
@@ -516,9 +548,9 @@ loop:
|
||||
// On other errors, allow the client to try again.
|
||||
data, err := s.readData()
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
switch err := err.(type) {
|
||||
case net.Error:
|
||||
if err.(net.Error).Timeout() {
|
||||
if err.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
|
||||
@@ -533,11 +565,13 @@ loop:
|
||||
|
||||
// Create Received header & write message body into buffer.
|
||||
buffer.Reset()
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
if len(to) > 0 {
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
}
|
||||
buffer.Write(data)
|
||||
|
||||
// Pass mail on to handler.
|
||||
if s.srv.Handler != nil {
|
||||
// Pass mail on to handler only if there are valid recipients.
|
||||
if len(to) > 0 && 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\-](.+)$`)
|
||||
@@ -549,8 +583,8 @@ loop:
|
||||
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())
|
||||
} else if len(to) > 0 && s.srv.MsgIDHandler != nil {
|
||||
msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username)
|
||||
if err != nil {
|
||||
checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`)
|
||||
if checkErrFormat.MatchString(err.Error()) {
|
||||
@@ -567,13 +601,21 @@ loop:
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
} else {
|
||||
if hasRejectedRecipients && Debug {
|
||||
if s.srv.LogWrite != nil {
|
||||
s.srv.LogWrite(s.remoteIP, "DEBUG", "Message from sender silently dropped (rejected recipients)")
|
||||
} else {
|
||||
log.Printf("%s DEBUG Message from sender silently dropped (rejected recipients)", s.remoteIP)
|
||||
}
|
||||
}
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
}
|
||||
|
||||
// Reset for next mail.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "QUIT":
|
||||
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName)
|
||||
@@ -585,8 +627,9 @@ loop:
|
||||
}
|
||||
s.writef("250 2.0.0 Ok")
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "NOOP":
|
||||
s.writef("250 2.0.0 Ok")
|
||||
@@ -661,8 +704,9 @@ loop:
|
||||
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
|
||||
s.remoteName = ""
|
||||
from = ""
|
||||
gotFrom = false
|
||||
gotFROM = false
|
||||
to = nil
|
||||
hasRejectedRecipients = false
|
||||
buffer.Reset()
|
||||
case "AUTH":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
@@ -682,7 +726,7 @@ loop:
|
||||
}
|
||||
|
||||
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
|
||||
if gotFrom || len(to) > 0 {
|
||||
if gotFROM || len(to) > 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
|
||||
break
|
||||
}
|
||||
@@ -748,7 +792,7 @@ func (s *session) writef(format string, args ...interface{}) {
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(s.bw, "%s\r\n", line)
|
||||
_, _ = fmt.Fprintf(s.bw, "%s\r\n", line)
|
||||
_ = s.bw.Flush()
|
||||
|
||||
if Debug {
|
||||
@@ -883,7 +927,12 @@ func (s *session) makeEHLOResponse() (response string) {
|
||||
}
|
||||
}
|
||||
|
||||
response += "250 ENHANCEDSTATUSCODES"
|
||||
response += "250-ENHANCEDSTATUSCODES\r\n"
|
||||
// RFC 6531 specifies that the presence of SMTPUTF8 should include 8BITMIME
|
||||
// "Servers offering this extension MUST provide support for, and announce, the 8BITMIME extension"
|
||||
// https://www.rfc-editor.org/rfc/rfc6531#section-3.1:
|
||||
response += "250-8BITMIME\r\n"
|
||||
response += "250 SMTPUTF8" // last entry must use a space instead of a dash
|
||||
return
|
||||
}
|
||||
|
||||
@@ -916,6 +965,12 @@ func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
|
||||
if authenticated {
|
||||
uname := string(username)
|
||||
s.username = &uname
|
||||
} else {
|
||||
s.username = nil
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
@@ -944,6 +999,12 @@ func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
|
||||
if authenticated {
|
||||
uname := string(parts[1])
|
||||
s.username = &uname
|
||||
} else {
|
||||
s.username = nil
|
||||
}
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
@@ -977,3 +1038,35 @@ func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
// Extract and validate email address from a regex match.
|
||||
// This ensures that only RFC 5322 compliant email addresses are accepted (if set).
|
||||
func extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) {
|
||||
match := re.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.Contains(match[1], " ") {
|
||||
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
|
||||
}
|
||||
|
||||
// first argument will be the email address, validate it if not empty
|
||||
if match[1] != "" {
|
||||
a, err := mail.ParseAddress(match[1])
|
||||
if err != nil {
|
||||
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1
|
||||
// RFC states that the local part of an email address SHOULD not exceed 64 characters
|
||||
// and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620
|
||||
// it appears that investigated mail servers do not actually implement this limit, but rather enforce
|
||||
// a much larger limit (ie: 1024 characters).
|
||||
if len(a.Address) > 1024 {
|
||||
return nil, errors.New("500 The address is too long")
|
||||
}
|
||||
}
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -40,7 +39,7 @@ func newConn(t *testing.T, server *Server) net.Conn {
|
||||
|
||||
// Send a command and verify the 3 digit code from the response.
|
||||
func cmdCode(t *testing.T, conn net.Conn, cmd string, code string) string {
|
||||
fmt.Fprintf(conn, "%s\r\n", cmd)
|
||||
_, _ = fmt.Fprintf(conn, "%s\r\n", cmd)
|
||||
resp, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response from test server: %v", err)
|
||||
@@ -72,7 +71,9 @@ func TestSimpleCommands(t *testing.T) {
|
||||
conn := newConn(t, &Server{})
|
||||
cmdCode(t, conn, tt.cmd, tt.code)
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Errorf("Failed to close connection after command %s: %v", tt.cmd, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ func TestCmdHELO(t *testing.T) {
|
||||
cmdCode(t, conn, "DATA", "503")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdEHLO(t *testing.T) {
|
||||
@@ -103,11 +104,59 @@ func TestCmdEHLO(t *testing.T) {
|
||||
// See RFC 2821 section 4.1.4 for more detail.
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
|
||||
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
|
||||
|
||||
// test invalid addresses & header injection
|
||||
cmdCode(t, conn, "RCPT TO: <recipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipient@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
|
||||
cmdCode(t, conn, "RCPT TO:<recipientexample.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient@test@example.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient@@example.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "501")
|
||||
cmdCode(t, conn, "RCPT TO:<recipient\rexample.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "553")
|
||||
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "501")
|
||||
cmdCode(t, conn, "RCPT TO: <>", "501") // empty address not allowed here
|
||||
|
||||
cmdCode(t, conn, "EHLO host.example.com", "250")
|
||||
cmdCode(t, conn, "DATA", "503")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMAILBeforeEHLO(t *testing.T) {
|
||||
conn := newConn(t, &Server{})
|
||||
// RFC 5321 §4.1.4 — Order of Commands states (emphasis added):
|
||||
// “The SMTP client MUST issue HELO or EHLO before any other SMTP commands.”
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "503")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMAILAfterRCPT(t *testing.T) {
|
||||
conn := newConn(t, &Server{})
|
||||
|
||||
// Send EHLO, expect greeting
|
||||
cmdCode(t, conn, "EHLO host.example.com", "250")
|
||||
|
||||
// Send MAIL FROM
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
|
||||
|
||||
// Send RCPT TO
|
||||
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
|
||||
|
||||
// MAIL FROM must not come after RCPT TO in the same transaction
|
||||
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "503")
|
||||
|
||||
// RSET to clear the transaction
|
||||
cmdCode(t, conn, "RSET", "250")
|
||||
|
||||
// Now the MAIL FROM should be accepted
|
||||
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "250")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdRSET(t *testing.T) {
|
||||
@@ -121,7 +170,7 @@ func TestCmdRSET(t *testing.T) {
|
||||
cmdCode(t, conn, "DATA", "503")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMAIL(t *testing.T) {
|
||||
@@ -130,7 +179,7 @@ func TestCmdMAIL(t *testing.T) {
|
||||
|
||||
// MAIL with no FROM arg should return 501 syntax error
|
||||
cmdCode(t, conn, "MAIL", "501")
|
||||
// MAIL with empty FROM arg should return 501 syntax error
|
||||
// // MAIL with empty FROM arg should return 501 syntax error
|
||||
cmdCode(t, conn, "MAIL FROM:", "501")
|
||||
cmdCode(t, conn, "MAIL FROM: ", "501")
|
||||
cmdCode(t, conn, "MAIL FROM: ", "501")
|
||||
@@ -144,6 +193,20 @@ func TestCmdMAIL(t *testing.T) {
|
||||
// MAIL with seemingly valid but noncompliant FROM arg (double space after the colon) should return 501 syntax error
|
||||
cmdCode(t, conn, "MAIL FROM: <sender@example.com>", "501")
|
||||
|
||||
// test invalid addresses & header injection
|
||||
cmdCode(t, conn, "MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersender@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
|
||||
cmdCode(t, conn, "MAIL FROM:<sender\rexample.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "501")
|
||||
cmdCode(t, conn, "MAIL FROM:<senderexample.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <sender@@example.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <sender@test@example.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "501")
|
||||
cmdCode(t, conn, "MAIL FROM: < sender@example.com >", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: < sender@example.com>", "553")
|
||||
cmdCode(t, conn, "MAIL FROM: <sender@example.com >", "553")
|
||||
|
||||
// MAIL with valid SIZE parameter should return 250 Ok
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE=1000", "250")
|
||||
|
||||
@@ -152,17 +215,17 @@ func TestCmdMAIL(t *testing.T) {
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE= ", "501")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE=foo", "501")
|
||||
|
||||
// MAIL with options should be ignored except for SIZE
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250") // ignored
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=1000", "250") // size detected
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=foo", "501") // ignored
|
||||
// MAIL with BODY parameter should be accepted (8BITMIME support)
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=1000", "250")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME,SIZE=foo", "501") // SIZE validation error
|
||||
|
||||
// TODO: MAIL with valid AUTH parameter should return 250 Ok
|
||||
|
||||
// TODO: MAIL with invalid AUTH parameter must return 501 syntax error
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMAILMaxSize(t *testing.T) {
|
||||
@@ -192,7 +255,7 @@ func TestCmdMAILMaxSize(t *testing.T) {
|
||||
|
||||
// Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2).
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdRCPT(t *testing.T) {
|
||||
@@ -211,6 +274,7 @@ func TestCmdRCPT(t *testing.T) {
|
||||
cmdCode(t, conn, "RCPT TO:", "501")
|
||||
cmdCode(t, conn, "RCPT TO: ", "501")
|
||||
cmdCode(t, conn, "RCPT TO: ", "501")
|
||||
cmdCode(t, conn, "RCPT TO:<@route.example user@example.com>", "553")
|
||||
|
||||
// RCPT with valid TO arg should return 250 Ok
|
||||
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
|
||||
@@ -239,7 +303,7 @@ func TestCmdRCPT(t *testing.T) {
|
||||
cmdCode(t, conn, "RCPT TO: <recipient@example.com>", "501")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdMaxRecipients(t *testing.T) {
|
||||
@@ -256,7 +320,7 @@ func TestCmdMaxRecipients(t *testing.T) {
|
||||
cmdCode(t, conn, "RCPT TO: <recipient5@example.com>", "452")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdDATA(t *testing.T) {
|
||||
@@ -286,7 +350,7 @@ func TestCmdDATA(t *testing.T) {
|
||||
cmdCode(t, conn, "Test message.\r\n.", "250")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdDATAWithMaxSize(t *testing.T) {
|
||||
@@ -323,7 +387,7 @@ func TestCmdDATAWithMaxSize(t *testing.T) {
|
||||
|
||||
// Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2).
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
type mockHandler struct {
|
||||
@@ -347,7 +411,7 @@ func TestCmdDATAWithHandler(t *testing.T) {
|
||||
cmdCode(t, conn, "DATA", "354")
|
||||
cmdCode(t, conn, "Test message.\r\n.", "250")
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
|
||||
if m.handlerCalled != 1 {
|
||||
t.Errorf("MailHandler called %d times, want one call", m.handlerCalled)
|
||||
@@ -364,7 +428,7 @@ func TestCmdDATAWithHandlerError(t *testing.T) {
|
||||
cmdCode(t, conn, "DATA", "354")
|
||||
cmdCode(t, conn, "Test message.\r\n.", "451")
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
|
||||
if m.handlerCalled != 1 {
|
||||
t.Errorf("MailHandler called %d times, want one call", m.handlerCalled)
|
||||
@@ -382,7 +446,7 @@ func TestCmdSTARTTLS(t *testing.T) {
|
||||
cmdCode(t, conn, "STARTTLS FOO", "501")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdSTARTTLSFailure(t *testing.T) {
|
||||
@@ -411,7 +475,7 @@ func TestCmdSTARTTLSFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
// Utility function to make a valid TLS certificate for use by the server.
|
||||
@@ -497,7 +561,7 @@ func TestCmdSTARTTLSSuccess(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "STARTTLS", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdSTARTTLSRequired(t *testing.T) {
|
||||
@@ -548,7 +612,7 @@ func TestCmdSTARTTLSRequired(t *testing.T) {
|
||||
}
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestMakeHeaders(t *testing.T) {
|
||||
@@ -798,8 +862,8 @@ func TestMakeEHLOResponse(t *testing.T) {
|
||||
t.Errorf("AUTH does not appear in the extension list when an AuthHandler is specified")
|
||||
}
|
||||
|
||||
reLogin := regexp.MustCompile("\\bLOGIN\\b")
|
||||
rePlain := regexp.MustCompile("\\bPLAIN\\b")
|
||||
reLogin := regexp.MustCompile(`\bLOGIN\b`)
|
||||
rePlain := regexp.MustCompile(`\bPLAIN\b`)
|
||||
|
||||
// RFC 4954 specifies that, without TLS in use, plaintext authentication mechanisms must not be advertised.
|
||||
s.tls = false
|
||||
@@ -820,88 +884,187 @@ func TestMakeEHLOResponse(t *testing.T) {
|
||||
if !rePlain.MatchString(extensions["AUTH"]) {
|
||||
t.Errorf("AUTH mechanism PLAIN does not appear in the extension list when an AuthHandler is specified and TLS is in use")
|
||||
}
|
||||
|
||||
// 8BITMIME should always be advertised
|
||||
s.srv = &Server{}
|
||||
s.tls = false
|
||||
extensions = parseExtensions(t, s.makeEHLOResponse())
|
||||
if _, ok := extensions["8BITMIME"]; !ok {
|
||||
t.Errorf("8BITMIME does not appear in the extension list")
|
||||
}
|
||||
|
||||
// SMTPUTF8 should always be advertised
|
||||
if _, ok := extensions["SMTPUTF8"]; !ok {
|
||||
t.Errorf("SMTPUTF8 does not appear in the extension list")
|
||||
}
|
||||
|
||||
// ENHANCEDSTATUSCODES should always be advertised
|
||||
if _, ok := extensions["ENHANCEDSTATUSCODES"]; !ok {
|
||||
t.Errorf("ENHANCEDSTATUSCODES does not appear in the extension list")
|
||||
}
|
||||
}
|
||||
|
||||
func createTmpFile(content string) (file *os.File, err error) {
|
||||
file, err = os.CreateTemp("", "")
|
||||
if err != nil {
|
||||
return
|
||||
// Test 8BITMIME BODY parameter parsing in MAIL FROM command
|
||||
func TestCmd8BITMIME(t *testing.T) {
|
||||
srv := &Server{}
|
||||
conn := newConn(t, srv)
|
||||
cmdCode(t, conn, "EHLO host.example.com", "250")
|
||||
|
||||
// Create a session to check internal state
|
||||
clientConn, serverConn := net.Pipe()
|
||||
session := srv.newSession(serverConn)
|
||||
go session.serve()
|
||||
|
||||
// Read and discard banner
|
||||
_, _ = bufio.NewReader(clientConn).ReadString('\n')
|
||||
|
||||
// Send EHLO
|
||||
_, _ = fmt.Fprintf(clientConn, "EHLO test.example.com\r\n")
|
||||
reader := bufio.NewReader(clientConn)
|
||||
for {
|
||||
line, _ := reader.ReadString('\n')
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, err = file.Write([]byte(content))
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
// Test BODY=8BITMIME parameter
|
||||
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> BODY=8BITMIME\r\n")
|
||||
resp, _ := reader.ReadString('\n')
|
||||
if !strings.HasPrefix(resp, "250") {
|
||||
t.Errorf("MAIL FROM with BODY=8BITMIME failed: %s", resp)
|
||||
}
|
||||
err = file.Close()
|
||||
return
|
||||
|
||||
// Verify bodyEncoding was set (we can't directly access it, but we can test the behavior)
|
||||
// Reset and test BODY=7BIT
|
||||
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> BODY=7BIT\r\n")
|
||||
resp, _ = reader.ReadString('\n')
|
||||
if !strings.HasPrefix(resp, "250") {
|
||||
t.Errorf("MAIL FROM with BODY=7BIT failed: %s", resp)
|
||||
}
|
||||
|
||||
// Test BODY parameter with SIZE parameter
|
||||
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> SIZE=1000 BODY=8BITMIME\r\n")
|
||||
resp, _ = reader.ReadString('\n')
|
||||
if !strings.HasPrefix(resp, "250") {
|
||||
t.Errorf("MAIL FROM with SIZE and BODY parameters failed: %s", resp)
|
||||
}
|
||||
|
||||
// Test case insensitivity
|
||||
_, _ = fmt.Fprintf(clientConn, "RSET\r\n")
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = fmt.Fprintf(clientConn, "MAIL FROM:<sender@example.com> body=8bitmime\r\n")
|
||||
resp, _ = reader.ReadString('\n')
|
||||
if !strings.HasPrefix(resp, "250") {
|
||||
t.Errorf("MAIL FROM with lowercase body parameter failed: %s", resp)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
_, _ = fmt.Fprintf(clientConn, "QUIT\r\n")
|
||||
_, _ = reader.ReadString('\n')
|
||||
_ = clientConn.Close()
|
||||
|
||||
// Also test via the original connection
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME", "250")
|
||||
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
|
||||
|
||||
cmdCode(t, conn, "RSET", "250")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=7BIT", "250")
|
||||
|
||||
cmdCode(t, conn, "RSET", "250")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com> BODY=8BITMIME SIZE=5000", "250")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func createTLSFiles() (
|
||||
certFile *os.File,
|
||||
keyFile *os.File,
|
||||
passphrase string,
|
||||
err error,
|
||||
) {
|
||||
const certPEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIDRzCCAi+gAwIBAgIJAKtg4oViVwv4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||
BAMMCWxvY2FsaG9zdDAgFw0xODA0MjAxMzMxNTBaGA8yMDg2MDUwODEzMzE1MFow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||
CgKCAQEA8h7vl0gUquis5jRtcnETyD+8WITZO0s53aIzp0Y+9HXiHW6FGJjbOZjM
|
||||
IvozNVni+83QWKumRTgeSzIIW2j4V8iFMSNrvWmhmCKloesXS1aY6H979e01Ve8J
|
||||
WAJFRe6vZJd6gC6Z/P+ELU3ie4Vtr1GYfkV7nZ6VFp5/V/5nxGFag5TUlpP5hcoS
|
||||
9r2kvXofosVwe3x3udT8SEbv5eBD4bKeVyJs/RLbxSuiU1358Y1cDdVuHjcvfm3c
|
||||
ajhheQ4vX9WXsk7LGGhnf1SrrPN/y+IDTXfvoHn+nJh4vMAB4yzQdE1V1N1AB8RA
|
||||
0yBVJ6dwxRrSg4BFrNWhj3gfsvrA7wIDAQABo4GZMIGWMB0GA1UdDgQWBBQ4/ncp
|
||||
befFuKH1hoYkPqLwuRrPRjAfBgNVHSMEGDAWgBQ4/ncpbefFuKH1hoYkPqLwuRrP
|
||||
RjAJBgNVHRMEAjAAMBEGCWCGSAGG+EIBAQQEAwIGQDALBgNVHQ8EBAMCBaAwEwYD
|
||||
VR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQBJBetEXiEIzKAEpXGX87j6aUON51Fdf6BiLMCghuGKyhnaOG32
|
||||
4KJhtvVoS3ZUKPylh9c2VdItYlhWp76zd7YKk+3xUOixWeTMQHIvCvRGTyFibOPT
|
||||
mApwp2pEnJCe4vjUrBaRhiyI+xnB70cWVF2qeernlLUeJA1mfYyQLz+v06ebDWOL
|
||||
c/hPVQFB94lEdiyjGO7RZfIe8KwcK48g7iv0LQU4+c9MoWM2ZsVM1AL2tHzokSeA
|
||||
u64gDTW4K0Tzx1ab7KmOFXYUjbz/xWuReMt33EwDXAErKCjbVt2T55Qx8UoKzSh1
|
||||
tY0KDHdnYOzgsm2HIj2xcJqbeylYQvckNnoC
|
||||
-----END CERTIFICATE-----`
|
||||
// func createTmpFile(content string) (file *os.File, err error) {
|
||||
// file, err = os.CreateTemp("", "")
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// _, err = file.Write([]byte(content))
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// err = file.Close()
|
||||
// return
|
||||
// }
|
||||
|
||||
const keyPEM = `-----BEGIN RSA PRIVATE KEY-----
|
||||
Proc-Type: 4,ENCRYPTED
|
||||
DEK-Info: AES-256-CBC,C16BF8745B2CDB53AC2B1D7609893AA0
|
||||
// func createTLSFiles() (
|
||||
// certFile *os.File,
|
||||
// keyFile *os.File,
|
||||
// passphrase string,
|
||||
// err error,
|
||||
// ) {
|
||||
// const certPEM = `-----BEGIN CERTIFICATE-----
|
||||
// MIIDRzCCAi+gAwIBAgIJAKtg4oViVwv4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||
// BAMMCWxvY2FsaG9zdDAgFw0xODA0MjAxMzMxNTBaGA8yMDg2MDUwODEzMzE1MFow
|
||||
// FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||
// CgKCAQEA8h7vl0gUquis5jRtcnETyD+8WITZO0s53aIzp0Y+9HXiHW6FGJjbOZjM
|
||||
// IvozNVni+83QWKumRTgeSzIIW2j4V8iFMSNrvWmhmCKloesXS1aY6H979e01Ve8J
|
||||
// WAJFRe6vZJd6gC6Z/P+ELU3ie4Vtr1GYfkV7nZ6VFp5/V/5nxGFag5TUlpP5hcoS
|
||||
// 9r2kvXofosVwe3x3udT8SEbv5eBD4bKeVyJs/RLbxSuiU1358Y1cDdVuHjcvfm3c
|
||||
// ajhheQ4vX9WXsk7LGGhnf1SrrPN/y+IDTXfvoHn+nJh4vMAB4yzQdE1V1N1AB8RA
|
||||
// 0yBVJ6dwxRrSg4BFrNWhj3gfsvrA7wIDAQABo4GZMIGWMB0GA1UdDgQWBBQ4/ncp
|
||||
// befFuKH1hoYkPqLwuRrPRjAfBgNVHSMEGDAWgBQ4/ncpbefFuKH1hoYkPqLwuRrP
|
||||
// RjAJBgNVHRMEAjAAMBEGCWCGSAGG+EIBAQQEAwIGQDALBgNVHQ8EBAMCBaAwEwYD
|
||||
// VR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3
|
||||
// DQEBCwUAA4IBAQBJBetEXiEIzKAEpXGX87j6aUON51Fdf6BiLMCghuGKyhnaOG32
|
||||
// 4KJhtvVoS3ZUKPylh9c2VdItYlhWp76zd7YKk+3xUOixWeTMQHIvCvRGTyFibOPT
|
||||
// mApwp2pEnJCe4vjUrBaRhiyI+xnB70cWVF2qeernlLUeJA1mfYyQLz+v06ebDWOL
|
||||
// c/hPVQFB94lEdiyjGO7RZfIe8KwcK48g7iv0LQU4+c9MoWM2ZsVM1AL2tHzokSeA
|
||||
// u64gDTW4K0Tzx1ab7KmOFXYUjbz/xWuReMt33EwDXAErKCjbVt2T55Qx8UoKzSh1
|
||||
// tY0KDHdnYOzgsm2HIj2xcJqbeylYQvckNnoC
|
||||
// -----END CERTIFICATE-----`
|
||||
|
||||
O13z7Yq7butaJmMfg9wRis9YnIDPsp4coYI6Ud+JGcP7iXoy95QMhovKWx25o1ol
|
||||
tvUTsrsG27fHGf9qG02KizApIVtO9c1e0swCWzFrKRQX0JDiZDmilb9xosBNNst1
|
||||
BOzOTRZEwFGSOCKZRBfSXyqC93TvLJ3DO9IUnKIeGt7upipvg29b/Dur/fyCy2WV
|
||||
bLHXwUTDBm7j49yfoEyGkDjoB2QO9wgcgbacbnQJQ25fTFUwZpZJEJv6o1tRhoYM
|
||||
ZMOhC9x1URmdHKN1+z2y5BrB6oNpParfeAMEvs/9FE6jJwYUR28Ql6Mhphfvr9W2
|
||||
5Gxd3J65Ao9Vi2I5j5X6aBuNjyhXN3ScLjPG4lVZm9RU/uTPEt81pig/d5nSAjvF
|
||||
Nfc08NuG3cnMyJSE/xScJ4D+GtX8U969wO4oKPCR4E/NFyXPR730ppupDFG6hzPD
|
||||
PDmiszDtU438JAZ8AuFa1LkbyFnEW6KVD4h7VRr8YDjirCqnkgjNSI6dFY0NQ8H7
|
||||
SyexB0lrceX6HZc+oNdAtkX3tYdzY3ExzUM5lSF1dkldnRbApLbqc4uuNIVXhXFM
|
||||
dJnoPdKAzM6i+2EeVUxWNdafKDxnjVSHIHzHfIFJLQ4GS5rnz9keRFdyDjQL07tT
|
||||
Lu9pPOmsadDXp7oSa81RgoCUfNZeR4jKpCk2BOft0L6ZSqwYFLcQHLIfJaGfn902
|
||||
TUOTxHt0KzEUYeYSrXC2a6cyvXAd1YI7lOgy60qG89VHyCc2v5Bs4c4FNUDC/+Dj
|
||||
4ZwogaAbSNkLaE0q3sYQRPdxSqLftyX0KitAgE7oGtdzBfe1cdBoozw3U67NEMMT
|
||||
6qvk5j7RepPRSrapHtK5pMMdg5XpKFWcOXZ26VHVrDCj4JKdjVb4iyiQi94VveV0
|
||||
w9+KcOtyrM7/jbQlCWnXpsIkP8VA/RIgh7CBn/h4oF1sO8ywP25OGQ7VWAVq1R9D
|
||||
8bl8GzIdR9PZpFyOxuIac4rPa8tkDeoXKs4cxoao7H/OZO9o9aTB7CJMTL9yv0Kb
|
||||
ntWuYxQchE6syoGsOgdGyZhaw4JeFkasDUP5beyNY+278NkzgGTOIMMTXIX46woP
|
||||
ehzHKGHXVGf7ZiSFF+zAHMXZRSwNVMkOYwlIoRg1IbvIRbAXqAR6xXQTCVzNG0SU
|
||||
cskojycBca1Cz3hDVIKYZd9beDhprVdr2a4K2nft2g2xRNjKPopsaqXx+VPibFUx
|
||||
X7542eQ3eAlhkWUuXvt0q5a9WJdjJp9ODA0/d0akF6JQlEHIAyLfoUKB1HYwgUGG
|
||||
6uRm651FDAab9U4cVC5PY1hfv/QwzpkNDkzgJAZ5SMOfZhq7IdBcqGd3lzPmq2FP
|
||||
Vy1LVZIl3eM+9uJx5TLsBHH6NhMwtNhFCNa/5ksodQYlTvR8IrrgWlYg4EL69vjS
|
||||
yt6HhhEN3lFCWvrQXQMp93UklbTlpVt6qcDXiC7HYbs3+EINargRd5Z+xL5i5vkN
|
||||
f9k7s0xqhloWNPZcyOXMrox8L81WOY+sP4mVlGcfDRLdEJ8X2ofJpOAcwYCnjsKd
|
||||
uEGsi+l2fTj/F+eZLE6sYoMprgJrbfeqtRWFguUgTn7s5hfU0tZ46al5d0vz8fWK
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
// const keyPEM = `-----BEGIN RSA PRIVATE KEY-----
|
||||
// Proc-Type: 4,ENCRYPTED
|
||||
// DEK-Info: AES-256-CBC,C16BF8745B2CDB53AC2B1D7609893AA0
|
||||
|
||||
passphrase = "test"
|
||||
// O13z7Yq7butaJmMfg9wRis9YnIDPsp4coYI6Ud+JGcP7iXoy95QMhovKWx25o1ol
|
||||
// tvUTsrsG27fHGf9qG02KizApIVtO9c1e0swCWzFrKRQX0JDiZDmilb9xosBNNst1
|
||||
// BOzOTRZEwFGSOCKZRBfSXyqC93TvLJ3DO9IUnKIeGt7upipvg29b/Dur/fyCy2WV
|
||||
// bLHXwUTDBm7j49yfoEyGkDjoB2QO9wgcgbacbnQJQ25fTFUwZpZJEJv6o1tRhoYM
|
||||
// ZMOhC9x1URmdHKN1+z2y5BrB6oNpParfeAMEvs/9FE6jJwYUR28Ql6Mhphfvr9W2
|
||||
// 5Gxd3J65Ao9Vi2I5j5X6aBuNjyhXN3ScLjPG4lVZm9RU/uTPEt81pig/d5nSAjvF
|
||||
// Nfc08NuG3cnMyJSE/xScJ4D+GtX8U969wO4oKPCR4E/NFyXPR730ppupDFG6hzPD
|
||||
// PDmiszDtU438JAZ8AuFa1LkbyFnEW6KVD4h7VRr8YDjirCqnkgjNSI6dFY0NQ8H7
|
||||
// SyexB0lrceX6HZc+oNdAtkX3tYdzY3ExzUM5lSF1dkldnRbApLbqc4uuNIVXhXFM
|
||||
// dJnoPdKAzM6i+2EeVUxWNdafKDxnjVSHIHzHfIFJLQ4GS5rnz9keRFdyDjQL07tT
|
||||
// Lu9pPOmsadDXp7oSa81RgoCUfNZeR4jKpCk2BOft0L6ZSqwYFLcQHLIfJaGfn902
|
||||
// TUOTxHt0KzEUYeYSrXC2a6cyvXAd1YI7lOgy60qG89VHyCc2v5Bs4c4FNUDC/+Dj
|
||||
// 4ZwogaAbSNkLaE0q3sYQRPdxSqLftyX0KitAgE7oGtdzBfe1cdBoozw3U67NEMMT
|
||||
// 6qvk5j7RepPRSrapHtK5pMMdg5XpKFWcOXZ26VHVrDCj4JKdjVb4iyiQi94VveV0
|
||||
// w9+KcOtyrM7/jbQlCWnXpsIkP8VA/RIgh7CBn/h4oF1sO8ywP25OGQ7VWAVq1R9D
|
||||
// 8bl8GzIdR9PZpFyOxuIac4rPa8tkDeoXKs4cxoao7H/OZO9o9aTB7CJMTL9yv0Kb
|
||||
// ntWuYxQchE6syoGsOgdGyZhaw4JeFkasDUP5beyNY+278NkzgGTOIMMTXIX46woP
|
||||
// ehzHKGHXVGf7ZiSFF+zAHMXZRSwNVMkOYwlIoRg1IbvIRbAXqAR6xXQTCVzNG0SU
|
||||
// cskojycBca1Cz3hDVIKYZd9beDhprVdr2a4K2nft2g2xRNjKPopsaqXx+VPibFUx
|
||||
// X7542eQ3eAlhkWUuXvt0q5a9WJdjJp9ODA0/d0akF6JQlEHIAyLfoUKB1HYwgUGG
|
||||
// 6uRm651FDAab9U4cVC5PY1hfv/QwzpkNDkzgJAZ5SMOfZhq7IdBcqGd3lzPmq2FP
|
||||
// Vy1LVZIl3eM+9uJx5TLsBHH6NhMwtNhFCNa/5ksodQYlTvR8IrrgWlYg4EL69vjS
|
||||
// yt6HhhEN3lFCWvrQXQMp93UklbTlpVt6qcDXiC7HYbs3+EINargRd5Z+xL5i5vkN
|
||||
// f9k7s0xqhloWNPZcyOXMrox8L81WOY+sP4mVlGcfDRLdEJ8X2ofJpOAcwYCnjsKd
|
||||
// uEGsi+l2fTj/F+eZLE6sYoMprgJrbfeqtRWFguUgTn7s5hfU0tZ46al5d0vz8fWK
|
||||
// -----END RSA PRIVATE KEY-----`
|
||||
|
||||
certFile, err = createTmpFile(certPEM)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
keyFile, err = createTmpFile(keyPEM)
|
||||
return
|
||||
}
|
||||
// passphrase = "test"
|
||||
|
||||
// certFile, err = createTmpFile(certPEM)
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
// keyFile, err = createTmpFile(keyPEM)
|
||||
// return
|
||||
// }
|
||||
|
||||
func TestAuthMechs(t *testing.T) {
|
||||
s := session{}
|
||||
@@ -966,7 +1129,7 @@ func TestCmdAUTH(t *testing.T) {
|
||||
cmdCode(t, conn, "AUTH", "502")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHOptional(t *testing.T) {
|
||||
@@ -1007,7 +1170,7 @@ func TestCmdAUTHOptional(t *testing.T) {
|
||||
cmdCode(t, conn, "*", "501")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHRequired(t *testing.T) {
|
||||
@@ -1056,7 +1219,7 @@ func TestCmdAUTHRequired(t *testing.T) {
|
||||
cmdCode(t, conn, "AUTH PLAIN", "504")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHLOGIN(t *testing.T) {
|
||||
@@ -1113,7 +1276,7 @@ func TestCmdAUTHLOGIN(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHLOGINFast(t *testing.T) {
|
||||
@@ -1165,7 +1328,7 @@ func TestCmdAUTHLOGINFast(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHPLAIN(t *testing.T) {
|
||||
@@ -1224,7 +1387,7 @@ func TestCmdAUTHPLAIN(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHPLAINEmpty(t *testing.T) {
|
||||
@@ -1283,7 +1446,7 @@ func TestCmdAUTHPLAINEmpty(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHPLAINFast(t *testing.T) {
|
||||
@@ -1335,7 +1498,7 @@ func TestCmdAUTHPLAINFast(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHPLAINFastAndEmpty(t *testing.T) {
|
||||
@@ -1387,7 +1550,7 @@ func TestCmdAUTHPLAINFastAndEmpty(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
// makeCRAMMD5Response is a helper function to create the CRAM-MD5 hash.
|
||||
@@ -1457,7 +1620,7 @@ func TestCmdAUTHCRAMMD5(t *testing.T) {
|
||||
cmdCode(t, conn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
func TestCmdAUTHCRAMMD5WithTLS(t *testing.T) {
|
||||
@@ -1523,7 +1686,7 @@ func TestCmdAUTHCRAMMD5WithTLS(t *testing.T) {
|
||||
cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503")
|
||||
|
||||
cmdCode(t, tlsConn, "QUIT", "221")
|
||||
tlsConn.Close()
|
||||
_ = tlsConn.Close()
|
||||
}
|
||||
|
||||
// Benchmark the mail handling without the network stack introducing latency.
|
||||
@@ -1540,17 +1703,17 @@ func BenchmarkReceive(b *testing.B) {
|
||||
|
||||
// Benchmark a full mail transaction.
|
||||
for i := 0; i < b.N; i++ {
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "HELO host.example.com")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "HELO host.example.com")
|
||||
_, _ = reader.ReadString('\n')
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "MAIL FROM:<sender@example.com>")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "MAIL FROM:<sender@example.com>")
|
||||
_, _ = reader.ReadString('\n')
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "RCPT TO:<recipient@example.com>")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "RCPT TO:<recipient@example.com>")
|
||||
_, _ = reader.ReadString('\n')
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "DATA")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "DATA")
|
||||
_, _ = reader.ReadString('\n')
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "Test message.\r\n.")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "Test message.\r\n.")
|
||||
_, _ = reader.ReadString('\n')
|
||||
fmt.Fprintf(clientConn, "%s\r\n", "QUIT")
|
||||
_, _ = fmt.Fprintf(clientConn, "%s\r\n", "QUIT")
|
||||
_, _ = reader.ReadString('\n')
|
||||
}
|
||||
}
|
||||
@@ -1589,11 +1752,180 @@ func TestCmdShutdown(t *testing.T) {
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
|
||||
// connection should now be closed
|
||||
fmt.Fprintf(conn, "%s\r\n", "HELO host.example.com")
|
||||
_, _ = fmt.Fprintf(conn, "%s\r\n", "HELO host.example.com")
|
||||
_, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != io.EOF {
|
||||
t.Errorf("Expected connection to be closed\n")
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
type mockDropRejectedHandler struct {
|
||||
handlerCalled int
|
||||
lastFrom string
|
||||
lastTo []string
|
||||
msgIDCalled int
|
||||
lastMsgIDFrom string
|
||||
lastMsgIDTo []string
|
||||
}
|
||||
|
||||
func (m *mockDropRejectedHandler) handler(remoteAddr net.Addr, from string, to []string, data []byte) error {
|
||||
m.handlerCalled++
|
||||
m.lastFrom = from
|
||||
m.lastTo = append([]string{}, to...) // copy slice
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDropRejectedHandler) msgIDHandler(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) {
|
||||
m.msgIDCalled++
|
||||
m.lastMsgIDFrom = from
|
||||
m.lastMsgIDTo = append([]string{}, to...) // copy slice
|
||||
return "test-message-id", nil
|
||||
}
|
||||
|
||||
// Test the IgnoreRejectedRecipients option
|
||||
func TestIgnoreRejectedRecipients(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
IgnoreRejectedRecipients bool
|
||||
handlerRcpt func(net.Addr, string, string) bool
|
||||
rcptCommands []struct{ addr, expectedCode string }
|
||||
expectedHandlerCalls int
|
||||
expectedHandlerRecipients []string
|
||||
useMsgIDHandler bool
|
||||
}{
|
||||
{
|
||||
name: "Disabled_DefaultBehavior",
|
||||
IgnoreRejectedRecipients: false,
|
||||
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
|
||||
return !strings.HasSuffix(to, "@rejected.com")
|
||||
},
|
||||
rcptCommands: []struct{ addr, expectedCode string }{
|
||||
{"valid@example.com", "250"},
|
||||
{"invalid@rejected.com", "550"},
|
||||
},
|
||||
expectedHandlerCalls: 1,
|
||||
expectedHandlerRecipients: []string{"valid@example.com"},
|
||||
},
|
||||
{
|
||||
name: "Enabled_MixedRecipients",
|
||||
IgnoreRejectedRecipients: true,
|
||||
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
|
||||
return !strings.HasSuffix(to, "@rejected.com")
|
||||
},
|
||||
rcptCommands: []struct{ addr, expectedCode string }{
|
||||
{"valid1@example.com", "250"},
|
||||
{"valid2@example.com", "250"},
|
||||
{"invalid1@rejected.com", "250"}, // Now accepted but dropped
|
||||
{"invalid2@rejected.com", "250"}, // Now accepted but dropped
|
||||
},
|
||||
expectedHandlerCalls: 1,
|
||||
expectedHandlerRecipients: []string{"valid1@example.com", "valid2@example.com"},
|
||||
},
|
||||
{
|
||||
name: "Enabled_AllRejected",
|
||||
IgnoreRejectedRecipients: true,
|
||||
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
|
||||
return false // Reject all
|
||||
},
|
||||
rcptCommands: []struct{ addr, expectedCode string }{
|
||||
{"test1@example.com", "250"}, // Accepted but dropped
|
||||
{"test2@example.com", "250"}, // Accepted but dropped
|
||||
},
|
||||
expectedHandlerCalls: 0, // No handler calls since all rejected
|
||||
expectedHandlerRecipients: nil,
|
||||
},
|
||||
{
|
||||
name: "Enabled_OnlyValid",
|
||||
IgnoreRejectedRecipients: true,
|
||||
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
|
||||
return strings.HasSuffix(to, "@valid.com")
|
||||
},
|
||||
rcptCommands: []struct{ addr, expectedCode string }{
|
||||
{"user1@valid.com", "250"},
|
||||
{"user2@valid.com", "250"},
|
||||
{"user3@valid.com", "250"},
|
||||
},
|
||||
expectedHandlerCalls: 1,
|
||||
expectedHandlerRecipients: []string{"user1@valid.com", "user2@valid.com", "user3@valid.com"},
|
||||
},
|
||||
{
|
||||
name: "Enabled_WithMsgIDHandler",
|
||||
IgnoreRejectedRecipients: true,
|
||||
handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool {
|
||||
return !strings.HasSuffix(to, "@rejected.com")
|
||||
},
|
||||
rcptCommands: []struct{ addr, expectedCode string }{
|
||||
{"valid@example.com", "250"},
|
||||
{"invalid@rejected.com", "250"}, // Accepted but dropped
|
||||
},
|
||||
expectedHandlerCalls: 1,
|
||||
expectedHandlerRecipients: []string{"valid@example.com"},
|
||||
useMsgIDHandler: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockDropRejectedHandler{}
|
||||
|
||||
server := &Server{
|
||||
Hostname: "mail.example.com",
|
||||
AppName: "TestMail",
|
||||
MaxRecipients: 100,
|
||||
HandlerRcpt: tt.handlerRcpt,
|
||||
IgnoreRejectedRecipients: tt.IgnoreRejectedRecipients,
|
||||
}
|
||||
|
||||
if tt.useMsgIDHandler {
|
||||
server.MsgIDHandler = mock.msgIDHandler
|
||||
} else {
|
||||
server.Handler = mock.handler
|
||||
}
|
||||
|
||||
conn := newConn(t, server)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cmdCode(t, conn, "HELO host.example.com", "250")
|
||||
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
|
||||
|
||||
// Send RCPT commands
|
||||
for _, rcpt := range tt.rcptCommands {
|
||||
cmdCode(t, conn, "RCPT TO:<"+rcpt.addr+">", rcpt.expectedCode)
|
||||
}
|
||||
|
||||
// Send DATA
|
||||
cmdCode(t, conn, "DATA", "354")
|
||||
cmdCode(t, conn, "Subject: Test\r\n\r\nTest message\r\n.", "250")
|
||||
cmdCode(t, conn, "QUIT", "221")
|
||||
|
||||
// Verify handler calls
|
||||
if tt.useMsgIDHandler {
|
||||
if mock.msgIDCalled != tt.expectedHandlerCalls {
|
||||
t.Errorf("Expected %d MsgIDHandler calls, got %d", tt.expectedHandlerCalls, mock.msgIDCalled)
|
||||
}
|
||||
if tt.expectedHandlerCalls > 0 {
|
||||
if mock.lastMsgIDFrom != "sender@example.com" {
|
||||
t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastMsgIDFrom)
|
||||
}
|
||||
if !reflect.DeepEqual(mock.lastMsgIDTo, tt.expectedHandlerRecipients) {
|
||||
t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastMsgIDTo)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mock.handlerCalled != tt.expectedHandlerCalls {
|
||||
t.Errorf("Expected %d handler calls, got %d", tt.expectedHandlerCalls, mock.handlerCalled)
|
||||
}
|
||||
if tt.expectedHandlerCalls > 0 {
|
||||
if mock.lastFrom != "sender@example.com" {
|
||||
t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastFrom)
|
||||
}
|
||||
if !reflect.DeepEqual(mock.lastTo, tt.expectedHandlerRecipients) {
|
||||
t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastTo)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
196
internal/snakeoil/snakeoil.go
Normal file
196
internal/snakeoil/snakeoil.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package snakeoil provides functionality to generate a temporary self-signed certificates
|
||||
// for testing purposes. It generates a public and private key pair, stores them in the
|
||||
// OS's temporary directory, returning the paths to these files.
|
||||
package snakeoil
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
var keys = make(map[string]KeyPair)
|
||||
|
||||
// KeyPair holds the public and private key paths for a self-signed certificate.
|
||||
type KeyPair struct {
|
||||
Public string
|
||||
Private string
|
||||
}
|
||||
|
||||
// Certificates returns all configured self-signed certificates in use,
|
||||
// used for file deletion on exit.
|
||||
func Certificates() map[string]KeyPair {
|
||||
return keys
|
||||
}
|
||||
|
||||
// Public returns the path to a generated PEM-encoded RSA public key.
|
||||
func Public(str string) string {
|
||||
domains, key, err := parse(str)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if pair, ok := keys[key]; ok {
|
||||
return pair.Public
|
||||
}
|
||||
|
||||
private, public, err := generate(domains)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
keys[key] = KeyPair{
|
||||
Public: public,
|
||||
Private: private,
|
||||
}
|
||||
|
||||
return public
|
||||
}
|
||||
|
||||
// Private returns the path to a generated PEM-encoded RSA private key.
|
||||
func Private(str string) string {
|
||||
domains, key, err := parse(str)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to parse domains: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if pair, ok := keys[key]; ok {
|
||||
return pair.Private
|
||||
}
|
||||
|
||||
private, public, err := generate(domains)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[tls] failed to generate public certificate: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
keys[key] = KeyPair{
|
||||
Public: public,
|
||||
Private: private,
|
||||
}
|
||||
|
||||
return private
|
||||
}
|
||||
|
||||
// Parse takes the original string input, removes the "sans:" prefix,
|
||||
// splits the result into individual domains, and returns a slice of unique domains,
|
||||
// along with a unique key that is a comma-separated list of these domains.
|
||||
func parse(str string) ([]string, string, error) {
|
||||
// remove "sans:" prefix
|
||||
str = str[5:]
|
||||
var domains []string
|
||||
// split the string by commas and trim whitespace
|
||||
for domain := range strings.SplitSeq(str, ",") {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain != "" && !tools.InArray(domain, domains) {
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
}
|
||||
|
||||
if len(domains) == 0 {
|
||||
return domains, "", errors.New("no valid domains provided")
|
||||
}
|
||||
|
||||
// generate sha256 hash of the domains to create a unique key
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(strings.Join(domains, ",")))
|
||||
key := base64.URLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return domains, key, nil
|
||||
}
|
||||
|
||||
// Generate a new self-signed certificate and return a public & private key paths.
|
||||
func generate(domains []string) (string, string, error) {
|
||||
logger.Log().Infof("[tls] generating temp self-signed certificate for: %s", strings.Join(domains, ","))
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||
// PEM encoding of private key
|
||||
keyPEM := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: keyBytes,
|
||||
},
|
||||
)
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour)
|
||||
|
||||
// create certificate template
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(0),
|
||||
Subject: pkix.Name{
|
||||
CommonName: domains[0],
|
||||
Organization: []string{"Mailpit self-signed certificate"},
|
||||
},
|
||||
DNSNames: domains,
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
|
||||
// create certificate using template
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
}
|
||||
|
||||
// PEM encoding of certificate
|
||||
certPem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: derBytes,
|
||||
},
|
||||
)
|
||||
|
||||
// Store the paths to the generated keys
|
||||
priv, err := os.CreateTemp("", ".mailpit-*-private.pem")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if _, err := priv.Write(keyPEM); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := priv.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pub, err := os.CreateTemp("", ".mailpit-*-public.pem")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if _, err := pub.Write(certPem); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := pub.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return priv.Name(), pub.Name(), nil
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func Check(email []byte, timeout int) (Response, error) {
|
||||
return r, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&r)
|
||||
|
||||
|
||||
@@ -57,13 +57,6 @@ func SetService(s string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout defines the timeout
|
||||
func SetTimeout(t int) {
|
||||
if t > 0 {
|
||||
timeout = t
|
||||
}
|
||||
}
|
||||
|
||||
// Ping returns whether a service is active or not
|
||||
func Ping() error {
|
||||
if service == "postmark" {
|
||||
|
||||
@@ -70,21 +70,22 @@ type Result struct {
|
||||
|
||||
// dial connects to spamd through TCP or a Unix socket.
|
||||
func (c *Client) dial() (connection, error) {
|
||||
if c.net == "tcp" {
|
||||
switch c.net {
|
||||
case "tcp":
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialTCP("tcp", nil, tcpAddr)
|
||||
} else if c.net == "unix" {
|
||||
case "unix":
|
||||
unixAddr, err := net.ResolveUnixAddr("unix", c.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialUnix("unix", nil, unixAddr)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported network type: %s", c.net)
|
||||
}
|
||||
|
||||
panic("Client.net must be either \"tcp\" or \"unix\"")
|
||||
}
|
||||
|
||||
// Report checks if message is spam or not, and returns score plus report
|
||||
@@ -103,7 +104,7 @@ func (c *Client) report(email []byte) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return nil, err
|
||||
@@ -221,7 +222,7 @@ func (c *Client) Ping() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,23 +7,36 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/updater"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// Stores cached version along with its expiry time and error count.
|
||||
// Used to minimize repeated version lookups and track consecutive errors.
|
||||
type versionCache struct {
|
||||
// github version string
|
||||
value string
|
||||
// time to expire the cache
|
||||
expiry time.Time
|
||||
// count of consecutive errors
|
||||
errCount int
|
||||
}
|
||||
|
||||
var (
|
||||
// to prevent hammering Github for latest version
|
||||
latestVersionCache string
|
||||
// Version cache storing the latest GitHub version
|
||||
vCache versionCache
|
||||
|
||||
// StartedAt is set to the current ime when Mailpit starts
|
||||
startedAt time.Time
|
||||
|
||||
// sync mutex to prevent race condition with simultaneous requests
|
||||
mu sync.RWMutex
|
||||
|
||||
smtpAccepted float64
|
||||
smtpAcceptedSize float64
|
||||
smtpRejected float64
|
||||
smtpIgnored float64
|
||||
smtpAccepted uint64
|
||||
smtpAcceptedSize uint64
|
||||
smtpRejected uint64
|
||||
smtpIgnored uint64
|
||||
)
|
||||
|
||||
// AppInformation struct
|
||||
@@ -36,34 +49,40 @@ type AppInformation struct {
|
||||
// Database path
|
||||
Database string
|
||||
// Database size in bytes
|
||||
DatabaseSize float64
|
||||
DatabaseSize uint64
|
||||
// Total number of messages in the database
|
||||
Messages float64
|
||||
Messages uint64
|
||||
// Total number of messages in the database
|
||||
Unread float64
|
||||
Unread uint64
|
||||
// Tags and message totals per tag
|
||||
Tags map[string]int64
|
||||
// Runtime statistics
|
||||
RuntimeStats struct {
|
||||
// Mailpit server uptime in seconds
|
||||
Uptime float64
|
||||
Uptime uint64
|
||||
// Current memory usage in bytes
|
||||
Memory uint64
|
||||
// Database runtime messages deleted
|
||||
MessagesDeleted float64
|
||||
MessagesDeleted uint64
|
||||
// Accepted runtime SMTP messages
|
||||
SMTPAccepted float64
|
||||
SMTPAccepted uint64
|
||||
// Total runtime accepted messages size in bytes
|
||||
SMTPAcceptedSize float64
|
||||
SMTPAcceptedSize uint64
|
||||
// Rejected runtime SMTP messages
|
||||
SMTPRejected float64
|
||||
SMTPRejected uint64
|
||||
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
|
||||
SMTPIgnored float64
|
||||
SMTPIgnored uint64
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates exponential backoff duration based on the error count.
|
||||
func getBackoff(errCount int) time.Duration {
|
||||
backoff := min(time.Duration(1<<errCount)*time.Minute, 30*time.Minute)
|
||||
return backoff
|
||||
}
|
||||
|
||||
// Load the current statistics
|
||||
func Load() AppInformation {
|
||||
func Load(detectLatestVersion bool) AppInformation {
|
||||
info := AppInformation{}
|
||||
info.Version = config.Version
|
||||
|
||||
@@ -71,26 +90,42 @@ func Load() AppInformation {
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
|
||||
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
|
||||
info.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds())
|
||||
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
|
||||
info.RuntimeStats.SMTPAccepted = smtpAccepted
|
||||
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
|
||||
info.RuntimeStats.SMTPRejected = smtpRejected
|
||||
info.RuntimeStats.SMTPIgnored = smtpIgnored
|
||||
|
||||
if latestVersionCache != "" {
|
||||
info.LatestVersion = latestVersionCache
|
||||
} else {
|
||||
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
|
||||
if err == nil {
|
||||
info.LatestVersion = latest
|
||||
latestVersionCache = latest
|
||||
if config.DisableVersionCheck {
|
||||
info.LatestVersion = "disabled"
|
||||
} else if detectLatestVersion {
|
||||
mu.RLock()
|
||||
cacheValid := time.Now().Before(vCache.expiry)
|
||||
cacheValue := vCache.value
|
||||
mu.RUnlock()
|
||||
|
||||
// clear latest version cache after 5 minutes
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
latestVersionCache = ""
|
||||
}()
|
||||
if cacheValid {
|
||||
info.LatestVersion = cacheValue
|
||||
} else {
|
||||
mu.Lock()
|
||||
// Re-check after acquiring write lock in case another goroutine refreshed it
|
||||
if time.Now().Before(vCache.expiry) {
|
||||
info.LatestVersion = vCache.value
|
||||
} else {
|
||||
latest, err := config.GHRUConfig.Latest()
|
||||
if err == nil {
|
||||
vCache = versionCache{value: latest.Tag, expiry: time.Now().Add(15 * time.Minute)}
|
||||
info.LatestVersion = latest.Tag
|
||||
} else {
|
||||
logger.Log().Errorf("Failed to fetch latest version: %v", err)
|
||||
vCache.errCount++
|
||||
vCache.value = ""
|
||||
vCache.expiry = time.Now().Add(getBackoff(vCache.errCount))
|
||||
info.LatestVersion = ""
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +147,7 @@ func Track() {
|
||||
func LogSMTPAccepted(size int) {
|
||||
mu.Lock()
|
||||
smtpAccepted = smtpAccepted + 1
|
||||
smtpAcceptedSize = smtpAcceptedSize + float64(size)
|
||||
smtpAcceptedSize = smtpAcceptedSize + tools.SafeUint64(size)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func dbCron() {
|
||||
if total == 0 {
|
||||
deletedPercent = 100
|
||||
} else {
|
||||
deletedPercent = deletedSize * 100 / total
|
||||
deletedPercent = float64(deletedSize * 100 / total)
|
||||
}
|
||||
// only vacuum the DB if at least 1% of mail storage size has been deleted
|
||||
if deletedPercent >= 1 {
|
||||
@@ -56,24 +56,23 @@ func pruneMessages() {
|
||||
start := time.Now()
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
var prunedSize uint64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
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 config.MaxMessages > 0 && CountTotal() > uint64(config.MaxMessages) {
|
||||
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) {
|
||||
if err := q.QueryAndClose(
|
||||
context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
@@ -81,12 +80,12 @@ func pruneMessages() {
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
},
|
||||
); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +109,7 @@ func pruneMessages() {
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
prunedSize = prunedSize + uint64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
dbFile string
|
||||
sqlDriver string
|
||||
dbLastAction time.Time
|
||||
|
||||
@@ -139,7 +138,6 @@ func InitDB() error {
|
||||
|
||||
LoadTagFilters()
|
||||
|
||||
dbFile = p
|
||||
dbLastAction = time.Now()
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
@@ -210,52 +208,51 @@ func StatsGet() MailboxStats {
|
||||
}
|
||||
|
||||
// CountTotal returns the number of emails in the database
|
||||
func CountTotal() float64 {
|
||||
var total float64
|
||||
func CountTotal() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountUnread returns the number of emails in the database that are unread.
|
||||
func CountUnread() float64 {
|
||||
var total float64
|
||||
func CountUnread() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 0).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// CountRead returns the number of emails in the database that are read.
|
||||
func CountRead() float64 {
|
||||
var total float64
|
||||
func CountRead() uint64 {
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
_ = sqlf.From(tenant("mailbox")).
|
||||
Select("COUNT(*)").To(&total).
|
||||
Where("Read = ?", 1).
|
||||
QueryRowAndClose(context.TODO(), db)
|
||||
|
||||
return total
|
||||
return uint64(total)
|
||||
}
|
||||
|
||||
// DbSize returns the size of the SQLite database.
|
||||
func DbSize() float64 {
|
||||
var total sql.NullFloat64
|
||||
func DbSize() uint64 {
|
||||
var total sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
|
||||
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
|
||||
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return total.Float64
|
||||
}
|
||||
|
||||
return total.Float64
|
||||
return uint64(total.Float64)
|
||||
}
|
||||
|
||||
// MessageIDExists checks whether a Message-ID exists in the DB
|
||||
|
||||
@@ -59,11 +59,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet()
|
||||
if float64(total) != s.Total {
|
||||
if uint64(total) != s.Total {
|
||||
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
|
||||
}
|
||||
|
||||
if float64(unread) != s.Unread {
|
||||
if uint64(unread) != s.Unread {
|
||||
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package storage
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5" // #nosec
|
||||
"crypto/sha1" // #nosec
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -19,14 +22,15 @@ import (
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/webhook"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
// Store will save an email to the database tables.
|
||||
// The username is the authentication username of either the SMTP or HTTP client (blank for none).
|
||||
// Returns the database ID of the saved message.
|
||||
func Store(body *[]byte) (string, error) {
|
||||
func Store(body *[]byte, username *string) (string, error) {
|
||||
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
|
||||
|
||||
// Parse message body with enmime
|
||||
@@ -44,13 +48,16 @@ func Store(body *[]byte) (string, error) {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
|
||||
obj := DBMailSummary{
|
||||
obj := Metadata{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
if username != nil {
|
||||
obj.Username = *username
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
@@ -82,17 +89,17 @@ func Store(body *[]byte) (string, error) {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
subject := env.GetHeader("Subject")
|
||||
size := float64(len(*body))
|
||||
size := uint64(len(*body))
|
||||
inline := len(env.Inlines)
|
||||
attachments := len(env.Attachments)
|
||||
snippet := tools.CreateSnippet(env.Text, env.HTML)
|
||||
|
||||
sql := fmt.Sprintf(`INSERT INTO %s
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
|
||||
tenant("mailbox"),
|
||||
) // #nosec
|
||||
|
||||
@@ -104,7 +111,7 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
if config.Compression > 0 {
|
||||
// insert compressed raw message
|
||||
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
|
||||
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size))
|
||||
|
||||
if sqlDriver == "rqlite" {
|
||||
// rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal
|
||||
@@ -114,8 +121,6 @@ func Store(body *[]byte) (string, error) {
|
||||
} else {
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec
|
||||
}
|
||||
|
||||
compressed = nil
|
||||
} else {
|
||||
// insert uncompressed raw message
|
||||
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant("mailbox_data")), id, string(*body)) // #nosec
|
||||
@@ -145,6 +150,11 @@ func Store(body *[]byte) (string, error) {
|
||||
tags = append(tags, obj.tagsFromPlusAddresses()...)
|
||||
}
|
||||
|
||||
// auto-tag by username if enabled
|
||||
if config.TagsUsername && username != nil && *username != "" {
|
||||
tags = append(tags, *username)
|
||||
}
|
||||
|
||||
// extract tags from search matches, and sort and extract unique tags
|
||||
tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...))
|
||||
|
||||
@@ -161,6 +171,24 @@ func Store(body *[]byte) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// we do not want to to broadcast null values for MetaData else this does not align
|
||||
// with the message summary documented in the API docs, so we set them to empty slices.
|
||||
if c.From == nil {
|
||||
c.From = &mail.Address{}
|
||||
}
|
||||
if c.To == nil {
|
||||
c.To = []*mail.Address{}
|
||||
}
|
||||
if c.Cc == nil {
|
||||
c.Cc = []*mail.Address{}
|
||||
}
|
||||
if c.Bcc == nil {
|
||||
c.Bcc = []*mail.Address{}
|
||||
}
|
||||
if c.ReplyTo == nil {
|
||||
c.ReplyTo = []*mail.Address{}
|
||||
}
|
||||
|
||||
c.Created = created
|
||||
c.ID = id
|
||||
c.MessageID = messageID
|
||||
@@ -177,7 +205,7 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, size)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
@@ -201,32 +229,41 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var metadataJSON string
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
em := MessageSummary{}
|
||||
var meta Metadata
|
||||
|
||||
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
|
||||
err := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
logger.Log().Errorf("[json] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
em.From = meta.From
|
||||
em.To = meta.To
|
||||
em.Cc = meta.Cc
|
||||
em.Bcc = meta.Bcc
|
||||
em.ReplyTo = meta.ReplyTo
|
||||
em.Username = meta.Username
|
||||
|
||||
em.Created = time.UnixMilli(int64(created))
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -271,12 +308,20 @@ func GetMessage(id string) (*Message, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var from *mail.Address
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
// Load metadata from DB
|
||||
meta, err := GetMetadata(id)
|
||||
if err != nil {
|
||||
meta = Metadata{}
|
||||
}
|
||||
|
||||
from := meta.From
|
||||
if from == nil {
|
||||
fromData := addressToSlice(env, "From")
|
||||
if len(fromData) > 0 {
|
||||
from = fromData[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
}
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
@@ -294,7 +339,7 @@ func GetMessage(id string) (*Message, error) {
|
||||
Where(`ID = ?`, id)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := row.Scan(&created); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -302,7 +347,6 @@ func GetMessage(id string) (*Message, error) {
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
|
||||
|
||||
date = time.UnixMilli(int64(created))
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -321,10 +365,10 @@ func GetMessage(id string) (*Message, error) {
|
||||
ReturnPath: returnPath,
|
||||
Subject: env.GetHeader("Subject"),
|
||||
Tags: getMessageTags(id),
|
||||
Size: float64(len(raw)),
|
||||
Size: uint64(len(raw)),
|
||||
Text: env.Text,
|
||||
Username: meta.Username,
|
||||
}
|
||||
|
||||
obj.HTML = env.HTML
|
||||
obj.Inline = []Attachment{}
|
||||
obj.Attachments = []Attachment{}
|
||||
@@ -462,7 +506,15 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
||||
}
|
||||
o.ContentType = a.ContentType
|
||||
o.ContentID = a.ContentID
|
||||
o.Size = float64(len(a.Content))
|
||||
o.Size = uint64(len(a.Content))
|
||||
|
||||
md5Hash := md5.Sum(a.Content) // #nosec
|
||||
sha1Hash := sha1.Sum(a.Content) // #nosec
|
||||
sha256Hash := sha256.Sum256(a.Content)
|
||||
|
||||
o.Checksums.MD5 = hex.EncodeToString(md5Hash[:])
|
||||
o.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:])
|
||||
o.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:])
|
||||
|
||||
return o
|
||||
}
|
||||
@@ -614,19 +666,21 @@ func DeleteMessages(ids []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
toDelete := []string{}
|
||||
var totalSize float64
|
||||
var totalSize uint64
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := rows.Scan(&id, &size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toDelete = append(toDelete, id)
|
||||
totalSize = totalSize + size
|
||||
totalSize = totalSize + uint64(size)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -663,7 +717,7 @@ func DeleteMessages(ids []string) error {
|
||||
}
|
||||
|
||||
dbLastAction = time.Now()
|
||||
addDeletedSize(int64(totalSize))
|
||||
addDeletedSize(totalSize)
|
||||
|
||||
logMessagesDeleted(len(toDelete))
|
||||
|
||||
@@ -711,7 +765,7 @@ func DeleteAllMessages() error {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
|
||||
|
||||
@@ -745,3 +799,17 @@ func DeleteAllMessages() error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetMetadata retrieves the metadata for a message by its ID
|
||||
func GetMetadata(id string) (Metadata, error) {
|
||||
var metadataJSON string
|
||||
row := db.QueryRow(fmt.Sprintf("SELECT Metadata FROM %s WHERE ID = ?", tenant("mailbox")), id)
|
||||
if err := row.Scan(&metadataJSON); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
var meta Metadata
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,13 +17,13 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -32,7 +33,7 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -54,13 +55,13 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
|
||||
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored")
|
||||
|
||||
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
@@ -70,7 +71,7 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
|
||||
assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted")
|
||||
|
||||
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
@@ -94,7 +95,7 @@ func TestRetrieveMimeEmail(t *testing.T) {
|
||||
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -122,14 +123,14 @@ func TestRetrieveMimeEmail(t *testing.T) {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
|
||||
assertEqual(t, uint64(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")
|
||||
assertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
Close()
|
||||
}
|
||||
@@ -151,7 +152,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
t.Logf("Testing message summary (tenant %s)", tenantID)
|
||||
}
|
||||
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -185,7 +186,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
@@ -197,10 +198,106 @@ func BenchmarkImportMime(b *testing.B) {
|
||||
defer Close()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
b.Log("error ", err)
|
||||
b.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestInlineImageContentIdHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing inline content handling")
|
||||
// Test case: Proper inline image with Content-Disposition: inline
|
||||
inlineAttachment, err := os.ReadFile("testdata/inline-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(&inlineAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 1:", err)
|
||||
}
|
||||
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 1:", err)
|
||||
}
|
||||
// Assert
|
||||
if len(msg.Inline) != 1 {
|
||||
t.Errorf("Test case 1: Expected 1 inline attachment, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 0 {
|
||||
t.Errorf("Test case 1: Expected 0 regular attachments, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Inline[0].ContentID != "test1@example.com" {
|
||||
t.Errorf("Test case 1: Expected ContentID 'test1@example.com', got '%s'", msg.Inline[0].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegularAttachmentHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing regular attachment handling")
|
||||
// Test case: Regular attachment without Content-ID
|
||||
regularAttachment, err := os.ReadFile("testdata/regular-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(®ularAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 3:", err)
|
||||
}
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 3:", err)
|
||||
}
|
||||
// Assert
|
||||
if len(msg.Inline) != 0 {
|
||||
t.Errorf("Test case 3: Expected 0 inline attachments, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 1 {
|
||||
t.Errorf("Test case 3: Expected 1 regular attachment, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Attachments[0].ContentID != "" {
|
||||
t.Errorf("Test case 3: Expected empty ContentID, got '%s'", msg.Attachments[0].ContentID)
|
||||
}
|
||||
|
||||
// Checksum tests
|
||||
assertEqual(t, msg.Attachments[0].Checksums.MD5, "b04930eb1ba0c62066adfa87e5d262c4", "Attachment MD5 checksum does not match")
|
||||
assertEqual(t, msg.Attachments[0].Checksums.SHA1, "15605d6a2fca44e966209d1701f16ecf816df880", "Attachment SHA1 checksum does not match")
|
||||
assertEqual(t, msg.Attachments[0].Checksums.SHA256, "92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec", "Attachment SHA256 checksum does not match")
|
||||
}
|
||||
|
||||
func TestMixedAttachmentHandling(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
t.Log("Testing mixed attachment handling")
|
||||
// Mixed scenario with both inline and regular attachment
|
||||
mixedAttachment, err := os.ReadFile("testdata/mixed-attachment.eml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test email: %v", err)
|
||||
}
|
||||
storedMessage, err := Store(&mixedAttachment, nil)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to store test case 4:", err)
|
||||
}
|
||||
msg, err := GetMessage(storedMessage)
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve test case 4:", err)
|
||||
}
|
||||
// Assert: Should have 1 inline (with ContentID) and 1 attachment (without ContentID)
|
||||
if len(msg.Inline) != 1 {
|
||||
t.Errorf("Test case 4: Expected 1 inline attachment, got %d", len(msg.Inline))
|
||||
}
|
||||
if len(msg.Attachments) != 1 {
|
||||
t.Errorf("Test case 4: Expected 1 regular attachment, got %d", len(msg.Attachments))
|
||||
}
|
||||
if msg.Inline[0].ContentID != "inline@example.com" {
|
||||
t.Errorf("Test case 4: Expected inline ContentID 'inline@example.com', got '%s'", msg.Inline[0].ContentID)
|
||||
}
|
||||
if msg.Attachments[0].ContentID != "" {
|
||||
t.Errorf("Test case 4: Expected attachment ContentID to be empty, got '%s'", msg.Attachments[0].ContentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
bcStatsDelay = false
|
||||
b := struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Version string
|
||||
}{
|
||||
Total: CountTotal(),
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ func ReindexAll() {
|
||||
err := sqlf.Select("ID").To(&i).
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
ids = append(ids, i)
|
||||
})
|
||||
|
||||
@@ -73,23 +73,22 @@ func ReindexAll() {
|
||||
continue
|
||||
}
|
||||
|
||||
from := &mail.Address{}
|
||||
meta, _ := GetMetadata(id)
|
||||
|
||||
fromJSON := addressToSlice(env, "From")
|
||||
if len(fromJSON) > 0 {
|
||||
from = fromJSON[0]
|
||||
meta.From = fromJSON[0]
|
||||
} else if env.GetHeader("From") != "" {
|
||||
from = &mail.Address{Name: env.GetHeader("From")}
|
||||
meta.From = &mail.Address{Name: env.GetHeader("From")}
|
||||
} else {
|
||||
meta.From = nil
|
||||
}
|
||||
meta.To = addressToSlice(env, "To")
|
||||
meta.Cc = addressToSlice(env, "Cc")
|
||||
meta.Bcc = addressToSlice(env, "Bcc")
|
||||
meta.ReplyTo = addressToSlice(env, "Reply-To")
|
||||
|
||||
obj := DBMailSummary{
|
||||
From: from,
|
||||
To: addressToSlice(env, "To"),
|
||||
Cc: addressToSlice(env, "Cc"),
|
||||
Bcc: addressToSlice(env, "Bcc"),
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
MetadataJSON, err := json.Marshal(obj)
|
||||
MetadataJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[message] %s", err.Error())
|
||||
continue
|
||||
@@ -115,7 +114,7 @@ func ReindexAll() {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// insert mail summary data
|
||||
for _, u := range updates {
|
||||
|
||||
@@ -39,12 +39,12 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var snippet string
|
||||
var read int
|
||||
@@ -65,7 +65,7 @@ func Search(search, timezone string, start int, beforeTS int64, limit int) ([]Me
|
||||
em.ID = id
|
||||
em.MessageID = messageID
|
||||
em.Subject = subject
|
||||
em.Size = size
|
||||
em.Size = uint64(size)
|
||||
em.Attachments = attachments
|
||||
em.Read = read == 1
|
||||
em.Snippet = snippet
|
||||
@@ -111,7 +111,7 @@ func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var unread int64
|
||||
var unread float64 // use float64 for rqlite compatibility
|
||||
|
||||
q = q.Where("Read = 0").Select(`COUNT(*)`)
|
||||
|
||||
@@ -128,9 +128,9 @@ func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
|
||||
|
||||
elapsed := time.Since(tsStart)
|
||||
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", unread, search, elapsed)
|
||||
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed)
|
||||
|
||||
return unread, err
|
||||
return int64(unread), err
|
||||
}
|
||||
|
||||
// DeleteSearch will delete all messages for search terms.
|
||||
@@ -141,15 +141,15 @@ func DeleteSearch(search, timezone string) error {
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
ids := []string{}
|
||||
deleteSize := float64(0)
|
||||
deleteSize := uint64(0)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -161,7 +161,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
deleteSize = deleteSize + size
|
||||
deleteSize = deleteSize + uint64(size)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
|
||||
// roll back if it fails
|
||||
defer tx.Rollback()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, ids := range chunks {
|
||||
delIDs := make([]interface{}, len(ids))
|
||||
@@ -247,7 +247,7 @@ func DeleteSearch(search, timezone string) error {
|
||||
}
|
||||
}
|
||||
|
||||
addDeletedSize(int64(deleteSize))
|
||||
addDeletedSize(deleteSize)
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
@@ -264,12 +264,12 @@ func SetSearchReadStatus(search, timezone string, read bool) error {
|
||||
ids := []string{}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var created float64 // use float64 for rqlite compatibility
|
||||
var id string
|
||||
var messageID string
|
||||
var subject string
|
||||
var metadata string
|
||||
var size float64
|
||||
var size float64 // use float64 for rqlite compatibility
|
||||
var attachments int
|
||||
var read int
|
||||
var snippet string
|
||||
@@ -519,7 +519,7 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
|
||||
//
|
||||
// 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 {
|
||||
func sizeToBytes(v string) uint64 {
|
||||
v = strings.ToLower(v)
|
||||
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
|
||||
|
||||
@@ -537,15 +537,15 @@ func sizeToBytes(v string) int64 {
|
||||
}
|
||||
|
||||
if unit == "" {
|
||||
return int64(i)
|
||||
return uint64(i)
|
||||
}
|
||||
|
||||
if unit == "k" || unit == "kb" {
|
||||
return int64(i * 1024)
|
||||
return uint64(i * 1024)
|
||||
}
|
||||
|
||||
if unit == "m" || unit == "mb" {
|
||||
return int64(i * 1024 * 1024)
|
||||
return uint64(i * 1024 * 1024)
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
@@ -48,7 +48,7 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
bufBytes := buf.Bytes()
|
||||
|
||||
if _, err := Store(&bufBytes); err != nil {
|
||||
if _, err := Store(&bufBytes, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -117,11 +117,11 @@ func TestSearchDelete100(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
if _, err := Store(&testMimeEmail); err != nil {
|
||||
if _, err := Store(&testMimeEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
|
||||
t.Log("Testing search delete of 1100 messages")
|
||||
for i := 0; i < 1100; i++ {
|
||||
if _, err := Store(&testTextEmail); err != nil {
|
||||
if _, err := Store(&testTextEmail, nil); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestEscPercentChar(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSizeToBytes(t *testing.T) {
|
||||
tests := map[string]int64{}
|
||||
tests := map[string]uint64{}
|
||||
tests["1m"] = 1048576
|
||||
tests["1mb"] = 1048576
|
||||
tests["1 M"] = 1048576
|
||||
|
||||
@@ -15,7 +15,7 @@ func SettingGet(k string) string {
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", k).
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return ""
|
||||
@@ -35,37 +35,37 @@ func SettingPut(k, v string) error {
|
||||
}
|
||||
|
||||
// The total deleted message size as an int64 value
|
||||
func getDeletedSize() float64 {
|
||||
var result sql.NullFloat64
|
||||
func getDeletedSize() uint64 {
|
||||
var result sql.NullFloat64 // use float64 for rqlite compatibility
|
||||
err := sqlf.From(tenant("settings")).
|
||||
Select("Value").To(&result).
|
||||
Where("Key = ?", "DeletedSize").
|
||||
Limit(1).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// The total raw non-compressed messages size in bytes of all messages in the database
|
||||
func totalMessagesSize() float64 {
|
||||
func totalMessagesSize() uint64 {
|
||||
var result sql.NullFloat64
|
||||
err := sqlf.From(tenant("mailbox")).
|
||||
Select("SUM(Size)").To(&result).
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return 0
|
||||
}
|
||||
|
||||
return result.Float64
|
||||
return uint64(result.Float64)
|
||||
}
|
||||
|
||||
// AddDeletedSize will add the value to the DeletedSize setting
|
||||
func addDeletedSize(v int64) {
|
||||
func addDeletedSize(v uint64) {
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -28,25 +28,27 @@ type Message struct {
|
||||
// Message subject
|
||||
Subject string
|
||||
// List-Unsubscribe header information
|
||||
// swagger:ignore
|
||||
ListUnsubscribe ListUnsubscribe
|
||||
// Message date if set, else date received
|
||||
// Message RFC3339Nano date & time (if set), else date & time received
|
||||
// ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Date time.Time
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message body text
|
||||
Text string
|
||||
// Message body HTML
|
||||
HTML string
|
||||
// Message size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
// Inline message attachments
|
||||
Inline []Attachment
|
||||
// Message attachments
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
// Attachment struct for inline and attachments
|
||||
// Attachment struct for inline images and attachments
|
||||
//
|
||||
// swagger:model Attachment
|
||||
type Attachment struct {
|
||||
@@ -59,7 +61,16 @@ type Attachment struct {
|
||||
// Content ID
|
||||
ContentID string
|
||||
// Size in bytes
|
||||
Size float64
|
||||
Size uint64
|
||||
// File checksums
|
||||
Checksums struct {
|
||||
// MD5 checksum hash of file
|
||||
MD5 string
|
||||
// SHA1 checksum hash of file
|
||||
SHA1 string
|
||||
// SHA256 checksum hash of file
|
||||
SHA256 string
|
||||
}
|
||||
}
|
||||
|
||||
// MessageSummary struct for frontend messages
|
||||
@@ -84,12 +95,14 @@ type MessageSummary struct {
|
||||
ReplyTo []*mail.Address
|
||||
// Email subject
|
||||
Subject string
|
||||
// Created time
|
||||
// Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)
|
||||
Created time.Time
|
||||
// Username used for authentication (if provided) with the SMTP or Send API
|
||||
Username string
|
||||
// Message tags
|
||||
Tags []string
|
||||
// Message size in bytes (total)
|
||||
Size float64
|
||||
Size uint64
|
||||
// Whether the message has any attachments
|
||||
Attachments int
|
||||
// Message snippet includes up to 250 characters
|
||||
@@ -98,18 +111,19 @@ type MessageSummary struct {
|
||||
|
||||
// MailboxStats struct for quick mailbox total/read lookups
|
||||
type MailboxStats struct {
|
||||
Total float64
|
||||
Unread float64
|
||||
Total uint64
|
||||
Unread uint64
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// DBMailSummary struct for storing mail summary
|
||||
type DBMailSummary struct {
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Cc []*mail.Address
|
||||
Bcc []*mail.Address
|
||||
ReplyTo []*mail.Address
|
||||
// Metadata struct for storing message metadata
|
||||
type Metadata struct {
|
||||
From *mail.Address `json:"From,omitempty"`
|
||||
To []*mail.Address `json:"To,omitempty"`
|
||||
Cc []*mail.Address `json:"Cc,omitempty"`
|
||||
Bcc []*mail.Address `json:"Bcc,omitempty"`
|
||||
ReplyTo []*mail.Address `json:"ReplyTo,omitempty"`
|
||||
Username string `json:"Username,omitempty"`
|
||||
}
|
||||
|
||||
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
|
||||
@@ -117,10 +131,10 @@ type DBMailSummary struct {
|
||||
type ListUnsubscribe struct {
|
||||
// List-Unsubscribe header value
|
||||
Header string
|
||||
// Detected links, maximum one email and one HTTP(S)
|
||||
// Detected links, maximum one email and one HTTP(S) link
|
||||
Links []string
|
||||
// Validation errors if any
|
||||
// Validation errors (if any)
|
||||
Errors string
|
||||
// List-Unsubscribe-Post value if set
|
||||
// List-Unsubscribe-Post value (if set)
|
||||
HeaderPost string
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func LoadTagFilters() {
|
||||
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
|
||||
continue
|
||||
}
|
||||
if t.Tags == nil || len(t.Tags) == 0 {
|
||||
if len(t.Tags) == 0 {
|
||||
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,17 +138,6 @@ func deleteMessageTag(id, name string) error {
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func DeleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
// GetAllTags returns all used tags
|
||||
func GetAllTags() []string {
|
||||
var tags = []string{}
|
||||
@@ -158,7 +147,7 @@ func GetAllTags() []string {
|
||||
Select(`DISTINCT Name`).
|
||||
From(tenant("tags")).To(&name).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
@@ -171,7 +160,7 @@ func GetAllTags() []string {
|
||||
func GetAllTagsCount() map[string]int64 {
|
||||
var tags = make(map[string]int64)
|
||||
var name string
|
||||
var total int64
|
||||
var total float64 // use float64 for rqlite compatibility
|
||||
|
||||
if err := sqlf.
|
||||
Select(`Name`).To(&name).
|
||||
@@ -180,8 +169,8 @@ func GetAllTagsCount() map[string]int64 {
|
||||
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
|
||||
GroupBy(tenant("message_tags.TagID")).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags[name] = int64(total)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -323,7 +312,7 @@ func findTagsInRawMessage(message *[]byte) []string {
|
||||
}
|
||||
|
||||
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
|
||||
func (d DBMailSummary) tagsFromPlusAddresses() []string {
|
||||
func (d Metadata) tagsFromPlusAddresses() []string {
|
||||
tags := []string{}
|
||||
for _, c := range d.To {
|
||||
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
|
||||
@@ -363,7 +352,7 @@ func getMessageTags(id string) []string {
|
||||
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
|
||||
Where(tenant("message_tags.ID")+` = ?`, id).
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
|
||||
tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[tags] %s", err.Error())
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
@@ -24,7 +26,7 @@ func TestTags(t *testing.T) {
|
||||
ids := []string{}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -57,7 +59,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err := Store(&testMimeEmail)
|
||||
id, err := Store(&testMimeEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -83,7 +85,7 @@ func TestTags(t *testing.T) {
|
||||
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 {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -109,7 +111,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
returnedTags = getMessageTags(id)
|
||||
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
|
||||
if err := DeleteAllMessageTags(id); err != nil {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -124,7 +126,7 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// test 20 tags
|
||||
id, err = Store(&testTagEmail)
|
||||
id, err = Store(&testTagEmail, nil)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -132,7 +134,7 @@ func TestTags(t *testing.T) {
|
||||
|
||||
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 {
|
||||
if err := deleteAllMessageTags(id); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
@@ -141,3 +143,59 @@ func TestTags(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
func TestUsernameAutoTagging(t *testing.T) {
|
||||
setup("")
|
||||
defer Close()
|
||||
|
||||
username := "testuser"
|
||||
|
||||
t.Run("Auto-tagging enabled", func(t *testing.T) {
|
||||
config.TagsUsername = true
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auto-tagging disabled", func(t *testing.T) {
|
||||
config.TagsUsername = false
|
||||
id, err := Store(&testTextEmail, &username)
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
msg, err := GetMessage(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessage failed: %v", err)
|
||||
}
|
||||
for _, tag := range msg.Tags {
|
||||
if tag == username {
|
||||
t.Errorf("Did not expect username '%s' in tags when disabled, got %v", username, msg.Tags)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAllMessageTags deleted all tags from a message
|
||||
func deleteAllMessageTags(id string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
ExecAndClose(context.TODO(), db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pruneUnusedTags()
|
||||
}
|
||||
|
||||
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal file
20
internal/storage/testdata/inline-attachment.eml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test inline image proper
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="boundary123"
|
||||
|
||||
--boundary123
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><img src="cid:test1@example.com" alt="Test"/></body></html>
|
||||
|
||||
--boundary123
|
||||
Content-Type: image/png; name="test1.png"
|
||||
Content-Disposition: inline; filename="test1.png"
|
||||
Content-ID: <test1@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
|
||||
|
||||
--boundary123--
|
||||
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal file
27
internal/storage/testdata/mixed-attachment.eml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test mixed attachments
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="boundary111"
|
||||
|
||||
--boundary111
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><img src="cid:inline@example.com" alt="Inline"/><p>Document attached</p></body></html>
|
||||
|
||||
--boundary111
|
||||
Content-Type: image/png; name="inline.png"
|
||||
Content-Disposition: inline; filename="inline.png"
|
||||
Content-ID: <inline@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==
|
||||
|
||||
--boundary111
|
||||
Content-Type: application/pdf; name="document.pdf"
|
||||
Content-Disposition: attachment; filename="document.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
|
||||
|
||||
--boundary111--
|
||||
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal file
19
internal/storage/testdata/regular-attachment.eml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test regular attachment
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="boundary789"
|
||||
|
||||
--boundary789
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<html><body><p>Message with regular attachment</p></body></html>
|
||||
|
||||
--boundary789
|
||||
Content-Type: application/pdf; name="document.pdf"
|
||||
Content-Disposition: attachment; filename="document.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo=
|
||||
|
||||
--boundary789--
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/internal/html2text"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// for stats to prevent import cycle
|
||||
mu sync.RWMutex
|
||||
// StatsDeleted for counting the number of messages deleted
|
||||
StatsDeleted float64
|
||||
StatsDeleted uint64
|
||||
)
|
||||
|
||||
// AddTempFile adds a file to the slice of files to delete on exit
|
||||
@@ -88,7 +89,7 @@ func cleanString(str string) string {
|
||||
// LogMessagesDeleted logs the number of messages deleted
|
||||
func logMessagesDeleted(n int) {
|
||||
mu.Lock()
|
||||
StatsDeleted = StatsDeleted + float64(n)
|
||||
StatsDeleted = StatsDeleted + tools.SafeUint64(n)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// IsFile returns whether a file exists and is readable
|
||||
func IsFile(path string) bool {
|
||||
f, err := os.Open(filepath.Clean(path))
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func ListUnsubscribeParser(v string) ([]string, error) {
|
||||
comments := reComments.FindAllStringSubmatch(v, -1)
|
||||
for _, c := range comments {
|
||||
// strip comments
|
||||
v = strings.Replace(v, c[0], "", -1)
|
||||
v = strings.ReplaceAll(v, c[0], "")
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
|
||||
28
internal/tools/net.go
Normal file
28
internal/tools/net.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
|
||||
// IsLoopback — 127.0.0.0/8, ::1
|
||||
// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
||||
// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
|
||||
// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16
|
||||
// IsUnspecified — 0.0.0.0, ::
|
||||
// IsMulticast — 224.0.0.0/4, ff00::/8
|
||||
func IsInternalIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast()
|
||||
}
|
||||
|
||||
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
|
||||
func IsValidLinkURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != ""
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
var (
|
||||
// Invalid tag characters regex
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`)
|
||||
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.@]`)
|
||||
|
||||
// Regex to catch multiple spaces
|
||||
multiSpaceRe = regexp.MustCompile(`(\s+)`)
|
||||
@@ -19,14 +19,21 @@ var (
|
||||
TagsTitleCase bool
|
||||
)
|
||||
|
||||
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
|
||||
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters.
|
||||
// If the tag is longer than 100 characters, it is truncated.
|
||||
func CleanTag(s string) string {
|
||||
return strings.TrimSpace(
|
||||
t := strings.TrimSpace(
|
||||
multiSpaceRe.ReplaceAllString(
|
||||
tagsInvalidChars.ReplaceAllString(s, " "),
|
||||
" ",
|
||||
),
|
||||
)
|
||||
|
||||
if len(t) > 100 {
|
||||
return t[:100]
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// SetTagCasing returns the slice of tags, title-casing if set
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestCleanTag(t *testing.T) {
|
||||
tests["thiS IS a Test :-)"] = "thiS IS a Test -"
|
||||
tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -"
|
||||
tests["this_is-a test "] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
|
||||
tests["this_is-a&^%%(*)@ test"] = "this_is-a @ test"
|
||||
tests["this is a long tag title with more than 100 characters, which should get automatically truncated to 100 characters"] = "this is a long tag title with more than 100 characters which should get automatically truncated to 1"
|
||||
|
||||
for search, expected := range tests {
|
||||
res := CleanTag(search)
|
||||
|
||||
@@ -36,3 +36,22 @@ func Normalize(s string) string {
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// SafeUint64 converts an int or int64 to uint64, ensuring it does not exceed the maximum value for uint64.
|
||||
func SafeUint64(i any) uint64 {
|
||||
switch v := i.(type) {
|
||||
case int:
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
case int64:
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(v)
|
||||
default:
|
||||
// only accepts int or int64
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// TarGZExtract extracts a archive from the file inputFilePath.
|
||||
// It tries to create the directory structure outputFilePath contains if it doesn't exist.
|
||||
// It returns potential errors to be checked or nil if everything works.
|
||||
func TarGZExtract(inputFilePath, outputFilePath string) (err error) {
|
||||
outputFilePath = stripTrailingSlashes(outputFilePath)
|
||||
inputFilePath, outputFilePath, err = makeAbsolute(inputFilePath, outputFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
undoDir, err := mkdirAll(outputFilePath, 0750)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
undoDir()
|
||||
}
|
||||
}()
|
||||
|
||||
return extract(inputFilePath, outputFilePath)
|
||||
}
|
||||
|
||||
// Creates all directories with os.MakedirAll and returns a function to remove the first created directory so cleanup is possible.
|
||||
func mkdirAll(dirPath string, perm os.FileMode) (func(), error) {
|
||||
var undoDir string
|
||||
|
||||
for p := dirPath; ; p = filepath.Dir(p) {
|
||||
finfo, err := os.Stat(p)
|
||||
if err == nil {
|
||||
if finfo.IsDir() {
|
||||
break
|
||||
}
|
||||
|
||||
finfo, err = os.Lstat(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if finfo.IsDir() {
|
||||
break
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("mkdirAll (%s): %v", p, syscall.ENOTDIR)
|
||||
}
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
undoDir = p
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if undoDir == "" {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirPath, perm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
if err := os.RemoveAll(undoDir); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove trailing slash if any.
|
||||
func stripTrailingSlashes(path string) string {
|
||||
if len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[0 : len(path)-1]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// Make input and output paths absolute.
|
||||
func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error) {
|
||||
inputFilePath, err := filepath.Abs(inputFilePath)
|
||||
if err == nil {
|
||||
outputFilePath, err = filepath.Abs(outputFilePath)
|
||||
}
|
||||
|
||||
return inputFilePath, outputFilePath, err
|
||||
}
|
||||
|
||||
// Extract the file in filePath to directory.
|
||||
func extract(filePath string, directory string) error {
|
||||
file, err := os.Open(filepath.Clean(filePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
fmt.Printf("Error closing file: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
gzipReader, err := gzip.NewReader(bufio.NewReader(file))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
|
||||
// Post extraction directory permissions & timestamps
|
||||
type DirInfo struct {
|
||||
Path string
|
||||
Header *tar.Header
|
||||
}
|
||||
|
||||
// slice to add all extracted directory info for post-processing
|
||||
postExtraction := []DirInfo{}
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileInfo := header.FileInfo()
|
||||
// paths could contain a '..', is used in a file system operations
|
||||
if strings.Contains(fileInfo.Name(), "..") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(directory, filepath.Dir(header.Name))
|
||||
filename := filepath.Join(dir, fileInfo.Name())
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
// create the directory 755 in case writing permissions prohibit writing before files added
|
||||
if err := os.MkdirAll(filename, 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set file ownership (if allowed)
|
||||
// Chtimes() && Chmod() only set after once extraction is complete
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
|
||||
// add directory info to slice to process afterwards
|
||||
postExtraction = append(postExtraction, DirInfo{filename, header})
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure parent directory exists (may not be included in tar)
|
||||
if !fileInfo.IsDir() && !isDir(dir) {
|
||||
err = os.MkdirAll(dir, 0750)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
n, err := tarReader.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
panic(err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
_, err = writer.Write(buffer[:n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = writer.Flush()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set file permissions, timestamps & uid/gid
|
||||
_ = os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
|
||||
_ = os.Chtimes(filename, header.AccessTime, header.ModTime)
|
||||
_ = os.Chown(filename, header.Uid, header.Gid)
|
||||
}
|
||||
|
||||
if len(postExtraction) > 0 {
|
||||
for _, dir := range postExtraction {
|
||||
_ = os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime)
|
||||
_ = os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Unzip will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (src) to an output directory (dest).
|
||||
func Unzip(src string, dest string) ([]string, error) {
|
||||
|
||||
var filenames []string
|
||||
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
|
||||
// Store filename/path for returning and using later on
|
||||
fpath := filepath.Join(dest, filepath.Clean(f.Name))
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return filenames, fmt.Errorf("%s: illegal file path", fpath)
|
||||
}
|
||||
|
||||
filenames = append(filenames, fpath)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Make File
|
||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(filepath.Clean(fpath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc) // #nosec - file is streamed from zip to file
|
||||
|
||||
// Close the file without defer to close before next iteration of loop
|
||||
if err := outFile.Close(); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
if err := rc.Close(); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
// package Updater checks and downloads new versions
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/semver"
|
||||
)
|
||||
|
||||
var (
|
||||
// AllowPrereleases defines whether pre-releases may be included
|
||||
AllowPrereleases = false
|
||||
|
||||
// temporary directory
|
||||
tempDir string
|
||||
)
|
||||
|
||||
// Releases struct for Github releases json
|
||||
type Releases []struct {
|
||||
Name string `json:"name"` // release name
|
||||
Tag string `json:"tag_name"` // release tag
|
||||
Prerelease bool `json:"prerelease"` // Github pre-release
|
||||
Assets []struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// Release struct contains the file data for downloadable release
|
||||
type Release struct {
|
||||
Name string
|
||||
Tag string
|
||||
URL string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// GithubLatest fetches the latest release info & returns release tag, filename & download url
|
||||
func GithubLatest(repo, name string) (string, string, string, error) {
|
||||
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
|
||||
|
||||
timeout := time.Duration(5 * time.Second)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", releaseURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
linkOS := runtime.GOOS
|
||||
linkArch := runtime.GOARCH
|
||||
linkExt := ".tar.gz"
|
||||
if linkOS == "windows" {
|
||||
// Windows uses .zip instead
|
||||
linkExt = ".zip"
|
||||
}
|
||||
|
||||
var allReleases = []Release{}
|
||||
|
||||
var releases Releases
|
||||
|
||||
if err := json.Unmarshal(body, &releases); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
archiveName := fmt.Sprintf("%s-%s-%s%s", name, linkOS, linkArch, linkExt)
|
||||
|
||||
// loop through releases
|
||||
for _, r := range releases {
|
||||
if !semver.IsValid(r.Tag) {
|
||||
// Invalid semversion, skip
|
||||
continue
|
||||
}
|
||||
|
||||
if !AllowPrereleases && (semver.Prerelease(r.Tag) != "" || r.Prerelease) {
|
||||
// we don't accept AllowPrereleases, skip
|
||||
continue
|
||||
}
|
||||
|
||||
for _, a := range r.Assets {
|
||||
if a.Name == archiveName {
|
||||
thisRelease := Release{a.Name, r.Tag, a.BrowserDownloadURL, a.Size}
|
||||
allReleases = append(allReleases, thisRelease)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allReleases) == 0 {
|
||||
// no releases with suitable assets found
|
||||
return "", "", "", fmt.Errorf("No binary releases found")
|
||||
}
|
||||
|
||||
var latestRelease = Release{}
|
||||
|
||||
for _, r := range allReleases {
|
||||
// detect the latest release
|
||||
if semver.Compare(r.Tag, latestRelease.Tag) == 1 {
|
||||
latestRelease = r
|
||||
}
|
||||
}
|
||||
|
||||
return latestRelease.Tag, latestRelease.Name, latestRelease.URL, nil
|
||||
}
|
||||
|
||||
// GreaterThan compares the current version to a different version
|
||||
// returning < 1 not upgradeable
|
||||
func GreaterThan(toVer, fromVer string) bool {
|
||||
return semver.Compare(toVer, fromVer) == 1
|
||||
}
|
||||
|
||||
// GithubUpdate the running binary with the latest release binary from Github
|
||||
func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
ver, filename, downloadURL, err := GithubLatest(repo, appName)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if ver == currentVersion {
|
||||
return "", fmt.Errorf("No new release found")
|
||||
}
|
||||
|
||||
if semver.Compare(ver, currentVersion) < 1 {
|
||||
return "", fmt.Errorf("No newer releases found (latest %s)", ver)
|
||||
}
|
||||
|
||||
tmpDir := getTempDir()
|
||||
|
||||
// outFile can be a tar.gz or a zip, depending on architecture
|
||||
outFile := filepath.Join(tmpDir, filename)
|
||||
|
||||
if err := downloadToFile(downloadURL, outFile); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newExec := filepath.Join(tmpDir, "mailpit")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := Unzip(outFile, tmpDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
newExec = filepath.Join(tmpDir, "mailpit.exe")
|
||||
} else {
|
||||
if err := TarGZExtract(outFile, tmpDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
err := os.Chmod(newExec, 0755) // #nosec
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the new binary is executable (mainly for inconsistent darwin builds)
|
||||
/* #nosec G204 */
|
||||
cmd := exec.Command(newExec, "-h")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// get the running binary
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = replaceFile(oldExec, newExec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
// DownloadToFile downloads a URL to a file
|
||||
func downloadToFile(url, fileName string) error {
|
||||
// Get the data
|
||||
resp, err := http.Get(url) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(filepath.Clean(fileName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := out.Close(); err != nil {
|
||||
logger.Log().Errorf("error closing file: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceFile replaces one file with another.
|
||||
// Running files cannot be overwritten, so it has to be moved
|
||||
// and the new binary saved to the original path. This requires
|
||||
// read & write permissions to both the original file and directory.
|
||||
// Note, on Windows it is not possible to delete a running program,
|
||||
// so the old exe is renamed and moved to os.TempDir()
|
||||
func replaceFile(dst, src string) error {
|
||||
// open the source file for reading
|
||||
source, err := os.Open(filepath.Clean(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// destination directory eg: /usr/local/bin
|
||||
dstDir := filepath.Dir(dst)
|
||||
// binary filename
|
||||
binaryFilename := filepath.Base(dst)
|
||||
// old binary tmp name
|
||||
dstOld := fmt.Sprintf("%s.old", binaryFilename)
|
||||
// new binary tmp name
|
||||
dstNew := fmt.Sprintf("%s.new", binaryFilename)
|
||||
// absolute path of new tmp file
|
||||
newTmpAbs := filepath.Join(dstDir, dstNew)
|
||||
// absolute path of old tmp file
|
||||
oldTmpAbs := filepath.Join(dstDir, dstOld)
|
||||
|
||||
// get src permissions
|
||||
fi, _ := os.Stat(dst)
|
||||
srcPerms := fi.Mode().Perm()
|
||||
|
||||
// create the new file
|
||||
tmpNew, err := os.OpenFile(filepath.Clean(newTmpAbs), os.O_CREATE|os.O_RDWR, srcPerms) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy new binary to <binary>.new
|
||||
if _, err := io.Copy(tmpNew, source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// close immediately else Windows has a fit
|
||||
if err := tmpNew.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := source.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the current executable to <binary>.old
|
||||
if err := os.Rename(dst, oldTmpAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the <binary>.new to current executable
|
||||
if err := os.Rename(newTmpAbs, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the old binary
|
||||
if runtime.GOOS == "windows" {
|
||||
tmpDir := os.TempDir()
|
||||
delFile := filepath.Join(tmpDir, filepath.Base(oldTmpAbs))
|
||||
if err := os.Rename(oldTmpAbs, delFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(oldTmpAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// remove the src file
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
// GetTempDir will create & return a temporary directory if one has not been specified
|
||||
func getTempDir() string {
|
||||
if tempDir == "" {
|
||||
randBytes := make([]byte, 6)
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tempDir = filepath.Join(os.TempDir(), "updater-"+hex.EncodeToString(randBytes))
|
||||
}
|
||||
if err := mkDirIfNotExists(tempDir); err != nil {
|
||||
// need a better way to exit
|
||||
logger.Log().Errorf("error: %s", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
return tempDir
|
||||
}
|
||||
|
||||
// MkDirIfNotExists will create a directory if it doesn't exist
|
||||
func mkDirIfNotExists(path string) error {
|
||||
if !isDir(path) {
|
||||
return os.MkdirAll(path, os.ModePerm) // #nosec
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDir returns if a path is a directory
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
26
main.go
26
main.go
@@ -1,3 +1,4 @@
|
||||
// Package main is the entrypoint
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -10,26 +11,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
exec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// running directly
|
||||
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) ||
|
||||
!strings.Contains(filepath.Base(os.Args[0]), "sendmail") {
|
||||
cmd.Execute()
|
||||
} else {
|
||||
// symlinked as "*sendmail*"
|
||||
// if the command executable contains "send" in the name (eg: sendmail), then run the sendmail command
|
||||
if strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), "send") {
|
||||
sendmail.Run()
|
||||
} else {
|
||||
// else run mailpit
|
||||
cmd.Execute()
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize returns a lowercase string stripped of the file extension (if exists).
|
||||
// Used for detecting Windows commands which ignores letter casing and `.exe`.
|
||||
// eg: "MaIlpIT.Exe" returns "mailpit"
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
return strings.TrimSuffix(s, filepath.Ext(s))
|
||||
}
|
||||
|
||||
2636
package-lock.json
generated
2636
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "mailpit",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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 internal/htmlcheck/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",
|
||||
"lint": "eslint --max-warnings 0 && prettier -c .",
|
||||
"lint-fix": "eslint --fix && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
"axios": "^1.13.5",
|
||||
"bootstrap": "^5.2.0",
|
||||
"bootstrap-icons": "^1.9.1",
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
@@ -27,12 +30,24 @@
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/tinycon": "^0.6.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"esbuild-plugin-vue-next": "^0.1.4",
|
||||
"esbuild-sass-plugin": "^3.0.0"
|
||||
"esbuild-sass-plugin": "^3.0.0",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-vue": "^10.2.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.5.3"
|
||||
},
|
||||
"prettier": {
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +121,14 @@ func Run() {
|
||||
// handles `sendmail -bs`
|
||||
// telnet directly to SMTP
|
||||
if UseB && UseS {
|
||||
var caller telnet.Caller = telnet.StandardCaller
|
||||
|
||||
if isSocket {
|
||||
var caller = telnet.StandardCaller
|
||||
switch isSocket {
|
||||
case true:
|
||||
if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -27,7 +30,7 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
}
|
||||
|
||||
if !isSocket {
|
||||
return smtp.SendMail(addr, nil, fromAddress.Address, to, msg)
|
||||
return sendMail(addr, nil, fromAddress.Address, to, msg)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
@@ -69,3 +72,76 @@ func Send(addr string, from string, to []string, msg []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
|
||||
if err := validateLine(from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, recipient := range to {
|
||||
if err := validateLine(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
if err = c.Hello(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
config := &tls.Config{ServerName: addr, InsecureSkipVerify: true} // #nosec
|
||||
if err = c.StartTLS(config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a != nil {
|
||||
if ok, _ := c.Extension("AUTH"); !ok {
|
||||
return errors.New("smtp: server doesn't support AUTH")
|
||||
}
|
||||
if err = c.Auth(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// validateLine checks to see if a line has CR or LF as per RFC 5321.
|
||||
func validateLine(line string) error {
|
||||
if strings.ContainsAny(line, "\n\r") {
|
||||
return errors.New("smtp: A line must not contain CR or LF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "404 page not found")
|
||||
_, _ = fmt.Fprint(w, "404 page not found")
|
||||
}
|
||||
|
||||
// HTTPError returns a basic error message (400 response)
|
||||
@@ -27,7 +27,7 @@ func httpError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, msg)
|
||||
_, _ = fmt.Fprint(w, msg)
|
||||
}
|
||||
|
||||
// httpJSONError returns a basic error message (400 response) in JSON format
|
||||
@@ -35,9 +35,7 @@ func httpJSONError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
e := JSONErrorMessage{
|
||||
Error: msg,
|
||||
}
|
||||
e := struct{ Error string }{Error: msg}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(e); err != nil {
|
||||
|
||||
@@ -10,16 +10,7 @@ import (
|
||||
"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.
|
||||
// AppInfo returns some basic details about the running app including the latest release (unless disabled).
|
||||
func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/info application AppInformation
|
||||
//
|
||||
@@ -37,58 +28,14 @@ func AppInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// 400: ErrorResponse
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(stats.Load(true)); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Response includes global web UI settings
|
||||
//
|
||||
// swagger:model WebUIConfiguration
|
||||
type webUIConfiguration struct {
|
||||
// Optional label to identify this Mailpit instance
|
||||
Label string
|
||||
// Message Relay information
|
||||
MessageRelay struct {
|
||||
// Whether message relaying (release) is enabled
|
||||
Enabled bool
|
||||
// The configured SMTP server address
|
||||
SMTPServer string
|
||||
// Enforced Return-Path (if set) for relay bounces
|
||||
ReturnPath string
|
||||
// Only allow relaying to these recipients (regex)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// swagger:route GET /api/v1/webui application WebUIConfigurationResponse
|
||||
//
|
||||
// # Get web UI configuration
|
||||
//
|
||||
@@ -104,26 +51,29 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// 200: WebUIConfigurationResponse
|
||||
// 400: ErrorResponse
|
||||
|
||||
conf := webUIConfiguration{}
|
||||
conf := webUIConfigurationResponse{}
|
||||
|
||||
conf.Label = config.Label
|
||||
conf.MessageRelay.Enabled = config.ReleaseEnabled
|
||||
conf.Body.Label = config.Label
|
||||
conf.Body.MessageRelay.Enabled = config.ReleaseEnabled
|
||||
if config.ReleaseEnabled {
|
||||
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
conf.Body.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
|
||||
conf.Body.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
|
||||
conf.Body.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.Body.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
|
||||
conf.Body.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom
|
||||
conf.Body.MessageRelay.PreserveMessageIDs = config.SMTPRelayConfig.PreserveMessageIDs
|
||||
|
||||
// DEPRECATED 2024/03/12
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
conf.Body.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
|
||||
}
|
||||
|
||||
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.ChaosEnabled = chaos.Enabled
|
||||
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
conf.Body.SpamAssassin = config.EnableSpamAssassin != ""
|
||||
conf.Body.ChaosEnabled = chaos.Enabled
|
||||
conf.Body.DuplicatesIgnored = config.IgnoreDuplicateIDs
|
||||
conf.Body.HideDeleteAllButton = config.HideDeleteAllButton
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(conf); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(conf.Body); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,6 @@ import (
|
||||
"github.com/axllent/mailpit/internal/smtpd/chaos"
|
||||
)
|
||||
|
||||
// ChaosTriggers are the Chaos 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
|
||||
@@ -50,12 +38,6 @@ func GetChaos(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -11,15 +11,6 @@ import (
|
||||
"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
|
||||
@@ -49,7 +40,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
_, _ = fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -66,19 +57,6 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -108,7 +86,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
_, _ = fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -132,21 +110,6 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -179,7 +142,7 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
_, _ = fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -199,15 +162,6 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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
|
||||
@@ -238,7 +192,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
_, _ = fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,55 +6,27 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
)
|
||||
|
||||
// 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 uint64 `json:"total"`
|
||||
|
||||
// Total number of unread messages in mailbox
|
||||
Unread float64 `json:"unread"`
|
||||
Unread uint64 `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"`
|
||||
Count uint64 `json:"count"`
|
||||
|
||||
// Total number of messages matching current query
|
||||
MessagesCount float64 `json:"messages_count"`
|
||||
MessagesCount uint64 `json:"messages_count"`
|
||||
|
||||
// Total number of unread messages matching current query
|
||||
MessagesUnreadCount float64 `json:"messages_unread"`
|
||||
MessagesUnreadCount uint64 `json:"messages_unread"`
|
||||
|
||||
// Pagination offset
|
||||
Start int `json:"start"`
|
||||
@@ -98,7 +70,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res.Start = start
|
||||
res.Messages = messages
|
||||
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Count = uint64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
@@ -111,39 +83,6 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:parameters SetReadStatusParams
|
||||
type setReadStatusParams struct {
|
||||
// in: body
|
||||
Body struct {
|
||||
// Read status
|
||||
//
|
||||
// required: false
|
||||
// default: false
|
||||
// example: true
|
||||
Read bool
|
||||
|
||||
// Optional array of message database IDs
|
||||
//
|
||||
// required: false
|
||||
// default: []
|
||||
// example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"]
|
||||
IDs []string
|
||||
|
||||
// Optional messages matching a search
|
||||
//
|
||||
// required: false
|
||||
// example: tag:backups
|
||||
Search string
|
||||
}
|
||||
|
||||
// Optional [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"`
|
||||
}
|
||||
|
||||
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs.
|
||||
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route PUT /api/v1/messages messages SetReadStatusParams
|
||||
@@ -225,19 +164,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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
|
||||
@@ -279,39 +205,6 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = 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
|
||||
// default: 0
|
||||
// type integer
|
||||
Start string `json:"start"`
|
||||
|
||||
// Limit results
|
||||
//
|
||||
// in: query
|
||||
// required: false
|
||||
// default: 50
|
||||
// type integer
|
||||
Limit string `json:"limit"`
|
||||
|
||||
// Optional [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
|
||||
@@ -349,9 +242,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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.Count = tools.SafeUint64(len(messages)) // legacy - now undocumented in API specs
|
||||
res.Total = stats.Total // total messages in mailbox
|
||||
res.MessagesCount = tools.SafeUint64(results)
|
||||
res.Unread = stats.Unread
|
||||
res.Tags = stats.Tags
|
||||
|
||||
@@ -361,7 +254,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
res.MessagesUnreadCount = float64(unread)
|
||||
res.MessagesUnreadCount = tools.SafeUint64(unread)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
@@ -369,23 +262,6 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -12,22 +12,9 @@ import (
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -93,25 +80,6 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -170,18 +138,6 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -210,7 +166,7 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
|
||||
id, err = storage.LatestID(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, err.Error())
|
||||
_, _ = fmt.Fprint(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,25 +17,6 @@ import (
|
||||
"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 ReleaseMessageParams
|
||||
@@ -176,13 +157,14 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// generate unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
// update Message-ID with unique ID
|
||||
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
if !config.SMTPRelayConfig.PreserveMessageIDs {
|
||||
// replace the Message-ID header with unique ID
|
||||
uid := shortuuid.New() + "@mailpit"
|
||||
msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">")
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := smtpd.Relay(from, data.To, msg); err != nil {
|
||||
|
||||
@@ -14,133 +14,9 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/smtpd"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
// swagger:parameters SendMessageParams
|
||||
type sendMessageParams struct {
|
||||
// in: body
|
||||
Body *SendRequest
|
||||
}
|
||||
|
||||
// SendRequest to send a message via HTTP
|
||||
// swagger:model SendRequest
|
||||
type SendRequest struct {
|
||||
// "From" recipient
|
||||
// required: true
|
||||
From struct {
|
||||
// Optional name
|
||||
// example: John Doe
|
||||
Name string
|
||||
// Email address
|
||||
// example: john@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// "To" recipients
|
||||
To []struct {
|
||||
// Optional name
|
||||
// example: Jane Doe
|
||||
Name string
|
||||
// Email address
|
||||
// example: jane@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Cc recipients
|
||||
Cc []struct {
|
||||
// Optional name
|
||||
// example: Manager
|
||||
Name string
|
||||
// Email address
|
||||
// example: manager@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Bcc recipients email addresses only
|
||||
// example: ["jack@example.com"]
|
||||
Bcc []string
|
||||
|
||||
// Optional Reply-To recipients
|
||||
ReplyTo []struct {
|
||||
// Optional name
|
||||
// example: Secretary
|
||||
Name string
|
||||
// Email address
|
||||
// example: secretary@example.com
|
||||
// required: true
|
||||
Email string
|
||||
}
|
||||
|
||||
// Subject
|
||||
// example: Mailpit message via the HTTP API
|
||||
Subject string
|
||||
|
||||
// Message body (text)
|
||||
// example: Mailpit is awesome!
|
||||
Text string
|
||||
|
||||
// Message body (HTML)
|
||||
// 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: 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: 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
|
||||
// example: ["Tag 1","Tag 2"]
|
||||
Tags []string
|
||||
|
||||
// Optional headers in {"key":"value"} format
|
||||
// example: {"X-IP":"1.2.3.4"}
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// JSONErrorMessage struct
|
||||
type JSONErrorMessage struct {
|
||||
// Error message
|
||||
// example: invalid format
|
||||
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 SendMessageParams
|
||||
@@ -158,8 +34,8 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: sendMessageResponse
|
||||
// 400: jsonErrorResponse
|
||||
// 200: SendMessageResponse
|
||||
// 400: JSONErrorResponse
|
||||
|
||||
if config.DemoMode {
|
||||
httpJSONError(w, "this functionality has been disabled for demonstration purposes")
|
||||
@@ -168,14 +44,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
data := SendRequest{}
|
||||
data := sendMessageParams{}
|
||||
|
||||
if err := decoder.Decode(&data); err != nil {
|
||||
if err := decoder.Decode(&data.Body); err != nil {
|
||||
httpJSONError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id, err := data.Send(r.RemoteAddr)
|
||||
var httpAuthUser *string
|
||||
if user, _, ok := r.BasicAuth(); ok {
|
||||
httpAuthUser = &user
|
||||
}
|
||||
|
||||
id, err := data.Send(r.RemoteAddr, httpAuthUser)
|
||||
|
||||
if err != nil {
|
||||
httpJSONError(w, err.Error())
|
||||
@@ -183,14 +64,14 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(struct{ ID string }{ID: id}); err != nil {
|
||||
httpError(w, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Send will validate the message structure and attempt to send to Mailpit.
|
||||
// It returns a sending summary or an error.
|
||||
func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
func (d sendMessageParams) Send(remoteAddr string, httpAuthUser *string) (string, error) {
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
|
||||
@@ -201,16 +82,16 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
addresses := []string{}
|
||||
|
||||
msg := enmime.Builder().
|
||||
From(d.From.Name, d.From.Email).
|
||||
Subject(d.Subject).
|
||||
Text([]byte(d.Text))
|
||||
From(d.Body.From.Name, d.Body.From.Email).
|
||||
Subject(d.Body.Subject).
|
||||
Text([]byte(d.Body.Text))
|
||||
|
||||
if d.HTML != "" {
|
||||
msg = msg.HTML([]byte(d.HTML))
|
||||
if d.Body.HTML != "" {
|
||||
msg = msg.HTML([]byte(d.Body.HTML))
|
||||
}
|
||||
|
||||
if len(d.To) > 0 {
|
||||
for _, a := range d.To {
|
||||
if len(d.Body.To) > 0 {
|
||||
for _, a := range d.Body.To {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.To(a.Name, a.Email)
|
||||
addresses = append(addresses, a.Email)
|
||||
@@ -220,8 +101,8 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Cc) > 0 {
|
||||
for _, a := range d.Cc {
|
||||
if len(d.Body.Cc) > 0 {
|
||||
for _, a := range d.Body.Cc {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.CC(a.Name, a.Email)
|
||||
addresses = append(addresses, a.Email)
|
||||
@@ -231,8 +112,8 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Bcc) > 0 {
|
||||
for _, e := range d.Bcc {
|
||||
if len(d.Body.Bcc) > 0 {
|
||||
for _, e := range d.Body.Bcc {
|
||||
if _, err := mail.ParseAddress(e); err == nil {
|
||||
msg = msg.BCC("", e)
|
||||
addresses = append(addresses, e)
|
||||
@@ -242,8 +123,8 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.ReplyTo) > 0 {
|
||||
for _, a := range d.ReplyTo {
|
||||
if len(d.Body.ReplyTo) > 0 {
|
||||
for _, a := range d.Body.ReplyTo {
|
||||
if _, err := mail.ParseAddress(a.Email); err == nil {
|
||||
msg = msg.ReplyTo(a.Name, a.Email)
|
||||
} else {
|
||||
@@ -254,13 +135,13 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
|
||||
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
|
||||
|
||||
if len(d.Tags) > 0 {
|
||||
msg = msg.Header("X-Tags", strings.Join(d.Tags, ", "))
|
||||
if len(d.Body.Tags) > 0 {
|
||||
msg = msg.Header("X-Tags", strings.Join(d.Body.Tags, ", "))
|
||||
restrictedHeaders = append(restrictedHeaders, "X-Tags")
|
||||
}
|
||||
|
||||
if len(d.Headers) > 0 {
|
||||
for k, v := range d.Headers {
|
||||
if len(d.Body.Headers) > 0 {
|
||||
for k, v := range d.Body.Headers {
|
||||
// check header isn't in "restricted" headers
|
||||
if tools.InArray(k, restrictedHeaders) {
|
||||
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
|
||||
@@ -269,8 +150,8 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(d.Attachments) > 0 {
|
||||
for _, a := range d.Attachments {
|
||||
if len(d.Body.Attachments) > 0 {
|
||||
for _, a := range d.Body.Attachments {
|
||||
// workaround: split string because JS readAsDataURL() returns the base64 string
|
||||
// with the mime type prefix eg: data:image/png;base64,<base64String>
|
||||
parts := strings.Split(a.Content, ",")
|
||||
@@ -302,5 +183,5 @@ func (d SendRequest) Send(remoteAddr string) (string, error) {
|
||||
return "", fmt.Errorf("error building message: %s", err.Error())
|
||||
}
|
||||
|
||||
return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes())
|
||||
return smtpd.SaveToDatabase(ipAddr, d.Body.From.Email, addresses, buff.Bytes(), httpAuthUser)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user