Compare commits

..

1 Commits

Author SHA1 Message Date
Ralph Slooten
fced6719b1 Merge branch 'release/1.1.6' 2022-09-19 22:18:01 +12:00
209 changed files with 6285 additions and 41664 deletions

47
.chglog/CHANGELOG.tpl.md Executable file
View File

@@ -0,0 +1,47 @@
# 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 -}}

12
.chglog/RELEASE.tpl.md Executable file
View File

@@ -0,0 +1,12 @@
{{ if .Versions -}}
{{ range .Versions }}
{{- if .CommitGroups -}}
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}
{{ end -}}

28
.chglog/config.yml Executable file
View File

@@ -0,0 +1,28 @@
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
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:
- Type
- Scope
- Subject
notes:
keywords:
- BREAKING CHANGE

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [axllent]
thanks_dev: u/gh/axllent

21
.github/SECURITY.md vendored
View File

@@ -1,21 +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, 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
View File

@@ -1,49 +0,0 @@
## 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"

View File

@@ -1,23 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
- package-ecosystem: "docker"
directory: "/" # Location of package manifests
schedule:
interval: "quarterly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "quarterly"

View File

@@ -1,52 +0,0 @@
on:
push:
branches: [ develop ]
name: Build docker edge images
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # required for github-action-get-previous-tag
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Get previous git tag
uses: WyriHaximus/github-action-get-previous-tag@v2
id: previous-tag
- name: Get short SHA
uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge
ghcr.io/${{ github.repository }}:edge

View File

@@ -8,46 +8,30 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Log into Docker Hub
uses: docker/login-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Parse semver
id: semver_parser
uses: booxmedialtd/ws-action-parse-semver@v1.4.7
with:
input_string: '${{ github.ref_name }}'
version_extractor_regex: 'v(.*)$'
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm
build-args: |
"VERSION=${{ github.ref_name }}"
"VERSION=${{ steps.tag.outputs.tag }}"
push: true
tags: |
axllent/mailpit:latest
axllent/mailpit:${{ github.ref_name }}
axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
ghcr.io/${{ github.repository }}:latest
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}

View File

@@ -1,24 +0,0 @@
name: Close stale issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- 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
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -38,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2

View File

@@ -5,35 +5,34 @@ on:
name: Build & release
jobs:
releases-matrix:
name: Build
name: Release Go Binary
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: ["386", amd64, arm, arm64]
goarch: ["386", amd64, arm64]
exclude:
- goarch: "386"
goos: darwin
- goarch: "386"
goos: windows
- goarch: arm
goos: darwin
- goarch: arm
- goarch: arm64
goos: windows
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
# build the assets
- uses: actions/setup-node@v6
- uses: actions/setup-node@v3
with:
node-version: 22
node-version: 16
cache: 'npm'
- run: echo "Building assets for ${{ github.ref_name }}"
- run: npm install
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1
- uses: wangyoucao577/go-release-action@v1.30
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
@@ -43,6 +42,4 @@ jobs:
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
extra_files: LICENSE README.md
md5sum: false
overwrite: true
retry: 5
ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}"
ldflags: -w -X "github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}"

View File

@@ -1,29 +0,0 @@
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"

View File

@@ -1,24 +1,22 @@
name: Tests
on:
pull_request:
branches: [ develop, 'feature/**' ]
branches: [ develop ]
push:
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
matrix:
go-version: [stable]
os: [ubuntu-latest, windows-latest, macos-latest]
go-version: [1.18.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v6
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
cache: false
- uses: actions/checkout@v6
- name: Set up Go environment
uses: actions/cache@v5
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
@@ -26,36 +24,13 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- 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=.
- run: go test ./storage -v
- run: go test ./storage -bench=.
# build the assets
- name: Set up node environment
if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v6
- uses: actions/setup-node@v3
with:
node-version: 22
node-version: 16
cache: 'npm'
- name: Install JavaScript dependencies
if: startsWith(matrix.os, 'ubuntu') == true
run: npm install
- 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
# default-timeout: 20000
- run: npm install
- run: npm run package

2
.gitignore vendored
View File

@@ -1,9 +1,7 @@
/node_modules/
/send
/sendmail/sendmail
/server/ui/dist
/Makefile
/mailpit*
/.idea
*.old
*.db

View File

@@ -1,9 +0,0 @@
# Not within the scope of Prettier
**/*.yml
**/*.yaml
**/*.json
**/*.md
**/*.css
**/*.html
**/*.scss
composer.lock

40
.vscode/settings.json vendored
View File

@@ -1,40 +0,0 @@
{
"[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"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
# 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!

View File

@@ -1,4 +1,4 @@
FROM golang:alpine AS builder
FROM golang:alpine as builder
ARG VERSION=dev
@@ -6,25 +6,15 @@ COPY . /app
WORKDIR /app
RUN apk upgrade && apk add git npm && \
RUN apk add --no-cache git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
FROM alpine:latest
LABEL org.opencontainers.image.title="Mailpit" \
org.opencontainers.image.description="An email and SMTP testing tool with API for developers" \
org.opencontainers.image.source="https://github.com/axllent/mailpit" \
org.opencontainers.image.url="https://mailpit.axllent.org" \
org.opencontainers.image.documentation="https://mailpit.axllent.org/docs/" \
org.opencontainers.image.licenses="MIT"
COPY --from=builder /mailpit /mailpit
RUN apk upgrade --no-cache && apk add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
RUN apk add --no-cache tzdata
ENTRYPOINT ["/mailpit"]

147
README.md
View File

@@ -1,124 +1,73 @@
<h1 align="center">
Mailpit - email testing for developers
</h1>
# Mailpit - email testing for developers
<div align="center">
<a href="https://github.com/axllent/mailpit/actions/workflows/tests.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg" alt="CI Tests status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/release-build.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg" alt="CI build status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg" alt="CI Docker build status"></a>
<a href="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml"><img src="https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg" alt="Code quality"></a>
<a href="https://goreportcard.com/report/github.com/axllent/mailpit"><img src="https://goreportcard.com/badge/github.com/axllent/mailpit" alt="Go Report Card"></a>
<br>
<a href="https://github.com/axllent/mailpit/releases/latest"><img src="https://img.shields.io/github/v/release/axllent/mailpit.svg" alt="Latest release"></a>
<a href="https://hub.docker.com/r/axllent/mailpit"><img src="https://img.shields.io/docker/pulls/axllent/mailpit.svg" alt="Docker pulls"></a>
</div>
<br>
<p align="center">
<a href="https://mailpit.axllent.org">Website</a> •
<a href="https://mailpit.axllent.org/docs/">Documentation</a> •
<a href="https://mailpit.axllent.org/docs/api-v1/">API</a>
</p>
![Tests](https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg)
![Build status](https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg)
![Docker builds](https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg)
![CodeQL](https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/axllent/mailpit)](https://goreportcard.com/report/github.com/axllent/mailpit)
<hr>
Mailpit is a multi-platform email testing tool for developers.
**Mailpit** is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
It acts as both an SMTP server, and provides a web interface to view all captured emails.
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and includes an API for automated integration testing.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
Mailpit was originally **inspired** by MailHog which is [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development or security updates for a few years now.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/server/ui-src/screenshot.png)
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
## Features
- Runs entirely from a single [static binary](https://mailpit.axllent.org/docs/install/) or multi-architecture [Docker images](https://mailpit.axllent.org/docs/install/docker/)
- Modern web UI with advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/) to view emails (formatted HTML, highlighted HTML source, text, headers, raw source, and MIME attachments
including image thumbnails), including optional [HTTPS](https://mailpit.axllent.org/docs/configuration/http/) & [authentication](https://mailpit.axllent.org/docs/configuration/http/)
- [SMTP server](https://mailpit.axllent.org/docs/configuration/smtp/) with optional STARTTLS or SSL/TLS, authentication (including an "accept any" mode)
- A [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing
- Real-time web UI updates using web sockets for new mail & optional [browser notifications](https://mailpit.axllent.org/docs/usage/notifications/) when new mail is received
- Optional [POP3 server](https://mailpit.axllent.org/docs/configuration/pop3/) to download captured message directly into your email client
- [HTML check](https://mailpit.axllent.org/docs/usage/html-check/) to test & score mail client compatibility with HTML emails
- [Link check](https://mailpit.axllent.org/docs/usage/link-check/) to test message links (HTML & text) & linked images
- [Spam check](https://mailpit.axllent.org/docs/usage/spamassassin/) to test message "spamminess" using a running SpamAssassin server
- [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI
- Mobile and tablet HTML preview toggle in desktop mode
- [Message tagging](https://mailpit.axllent.org/docs/usage/tagging/) including manual tagging or automated tagging using filtering and "plus addressing"
- [SMTP relaying](https://mailpit.axllent.org/docs/configuration/smtp-relay/) (message release) - relay messages via a different SMTP server including an optional allowlist of accepted recipients
- [SMTP forwarding](https://mailpit.axllent.org/docs/configuration/smtp-forward/) - automatically forward messages via a different SMTP server to predefined email addresses
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
- [Chaos](https://mailpit.axllent.org/docs/integration/chaos/) feature to enable configurable SMTP errors to test application resilience
- `List-Unsubscribe` syntax validation
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (formatted HTML, highlighted HTML source, text, raw source and MIME attachments including image thumbnails)
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
- Real-time web UI updates using web sockets for new mail
- Optional browser notifications for new mail (HTTPS only)
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size
- Can handle hundreds of thousands of emails
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
## Installation
The Mailpit web UI listens by default on `http://0.0.0.0:8025` and the SMTP port on `0.0.0.0:1025`.
Mailpit runs as a single binary and can be installed in different ways:
### Install via package managers
- **Mac**: `brew install mailpit` (to run automatically in the background: `brew services start mailpit`)
- **Arch Linux**: available in the AUR as `mailpit`
- **FreeBSD**: `pkg install mailpit`
### Install via script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```shell
sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
You can also change the install path to something else by setting the `INSTALL_PATH` environment, for example:
Or download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information.
```shell
sudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
### Download static binary (Windows, Linux and Mac)
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can be extracted and copied to your `$PATH`, or simply run as `./mailpit`.
### Docker
See [Docker instructions](https://mailpit.axllent.org/docs/install/docker/) for 386, amd64 & arm64 images.
### Compile from source
To build Mailpit from source, see [Building from source](https://mailpit.axllent.org/docs/install/source/).
## Usage
Run `mailpit -h` to see options. More information can be seen in [the docs](https://mailpit.axllent.org/docs/configuration/runtime-options/).
If installed using homebrew, you may run `brew services start mailpit` to always run mailpit automatically.
### Testing Mailpit
Please refer to [the documentation](https://mailpit.axllent.org/docs/install/testing/) on how to easily test email delivery to Mailpit.
The Mailpit web UI listens by default on `http://0.0.0.0:8025`, and the SMTP port on `0.0.0.0:1025`.
### Configuring sendmail
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/).
There are several different options available:
---
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
```
sendmail_path = /usr/local/bin/mailpit 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>
If Mailpit is found on the same host as sendmail, you can symlink the Mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if Mailpit is running on default 1025 port).
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
## Why rewrite MailHog?
I had been using MailHog for a few years to intercept and test emails generated from several projects. MailHog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects) and has too many unnecessary features for my purpose. It performs exceptionally poorly when dealing with large amounts of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute to ingest). The API also transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

View File

@@ -1,36 +0,0 @@
package cmd
import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/dump"
"github.com/axllent/mailpit/internal/logger"
"github.com/spf13/cobra"
)
// dumpCmd represents the dump command
var dumpCmd = &cobra.Command{
Use: "dump <database> <output-dir>",
Short: "Dump all messages from a database to a directory",
Long: `Dump all messages stored in Mailpit into a local directory as individual files.
The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a
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(_ *cobra.Command, args []string) {
if err := dump.Sync(args[0]); err != nil {
logger.Log().Fatal(err)
}
},
}
func init() {
rootCmd.AddCommand(dumpCmd)
dumpCmd.Flags().SortFlags = false
dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file")
dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)")
dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)")
dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
}

View File

@@ -1,173 +0,0 @@
package cmd
import (
"bytes"
"fmt"
"io"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/axllent/mailpit/internal/logger"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
ingestRecent int
)
// ingestCmd represents the ingest command
var ingestCmd = &cobra.Command{
Use: "ingest <file|folder> ...[file|folder]",
Short: "Ingest a file or folder of emails for testing",
Long: `Ingest a file or folder of emails for testing.
This command will scan the folder for emails and deliver them via SMTP to a running
Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox).
The --recent flag will only consider files with a modification date within the last X days.`,
// Hidden: true,
Args: cobra.MinimumNArgs(1),
Run: func(_ *cobra.Command, args []string) {
var count int
var total int
var per100start = time.Now()
for _, a := range args {
err := filepath.Walk(a,
func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Log().Error(err)
return nil
}
if !isFile(path) {
return nil
}
if ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
return nil
}
f, err := os.Open(filepath.Clean(path))
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
defer func() { _ = f.Close() }()
body, err := io.ReadAll(f)
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
logger.Log().Errorf("error parsing message body: %s", err.Error())
return nil
}
recipients := []string{}
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
recipients = append(recipients, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
recipients = append(recipients, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
recipients = append(recipients, a.Address)
}
}
if sendmail.FromAddr == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
sendmail.FromAddr = fromAddresses[0].Address
}
}
if len(recipients) == 0 {
// Bcc
recipients = []string{sendmail.FromAddr}
}
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
returnPath = fromAddresses[0].Address
}
}
err = sendmail.Send(sendmail.SMTPAddr, returnPath, recipients, body)
if err != nil {
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
return nil
}
count++
total++
if count%100 == 0 {
logger.Log().Infof("[%s] 100 messages in %s", format(total), time.Since(per100start))
per100start = time.Now()
}
return nil
})
if err != nil {
logger.Log().Error(err)
}
}
},
}
func init() {
rootCmd.AddCommand(ingestCmd)
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// Format a an integer 10000 => 10,000
func format(n int) string {
in := fmt.Sprintf("%d", n)
numOfDigits := len(in)
if n < 0 {
numOfDigits-- // First character is the - sign (not a digit)
}
numOfCommas := (numOfDigits - 1) / 3
out := make([]byte, len(in)+numOfCommas)
if n < 0 {
in, out[0] = in[1:], '-'
}
for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
out[j] = in[i]
if i == 0 {
return string(out)
}
if k++; k == 3 {
j, k = j-1, 0
out[j] = ','
}
}
}

View File

@@ -1,77 +0,0 @@
package cmd
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/spf13/cobra"
)
var (
useHTTPS bool
)
// readyzCmd represents the healthcheck command
var readyzCmd = &cobra.Command{
Use: "readyz",
Short: "Run a healthcheck to test if Mailpit is running",
Long: `This command connects to the /readyz endpoint of a running Mailpit server
and exits with a status of 0 if the connection is successful, else with a
status 1 if unhealthy.
If running within Docker, it should automatically detect environment
settings to determine the HTTP bind interface & port.
`,
Run: func(_ *cobra.Command, _ []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
proto = "https"
}
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
conf := &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// do not verify TLS if this instance is using HTTPS as we connect using IP
// so won't be the same as the cert
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}
client := &http.Client{Transport: conf}
res, err := client.Get(uri)
if err != nil || res.StatusCode != 200 {
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(readyzCmd)
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
if config.UITLSCert != "" {
useHTTPS = true
}
readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port")
readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)")
}

View File

@@ -1,36 +0,0 @@
package cmd
import (
"os"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/spf13/cobra"
)
// reindexCmd represents the reindex command
var reindexCmd = &cobra.Command{
Use: "reindex <database>",
Short: "Reindex the database",
Long: `This will reindex all messages in the entire database.
If you have several thousand messages in your mailbox, then it is advised to shut down
Mailpit while you reindex as this process will likely result in database locking issues.`,
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
config.Database = args[0]
config.MaxMessages = 0
if err := storage.InitDB(); err != nil {
logger.Log().Error(err)
os.Exit(1)
}
storage.ReindexAll()
},
}
func init() {
rootCmd.AddCommand(reindexCmd)
}

View File

@@ -1,24 +1,20 @@
// Package cmd is the main application
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/prometheus"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/spf13/cobra"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mailpit",
@@ -29,30 +25,21 @@ It acts as an SMTP server, and provides a web interface to view all captured ema
Documentation:
https://github.com/axllent/mailpit
https://mailpit.axllent.org/docs/`,
https://github.com/axllent/mailpit/wiki`,
Run: func(_ *cobra.Command, _ []string) {
if err := config.VerifyConfig(); err != nil {
logger.Log().Error(err.Error())
os.Exit(1)
}
if err := storage.InitDB(); err != nil {
logger.Log().Fatal(err.Error())
logger.Log().Error(err.Error())
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 {
storage.Close()
logger.Log().Fatal(err.Error())
logger.Log().Error(err.Error())
os.Exit(1)
}
},
@@ -67,6 +54,14 @@ 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
@@ -77,378 +72,86 @@ func init() {
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// load and warn deprecated ENV vars
initDeprecatedConfigFromEnv()
// load environment variables
initConfigFromEnv()
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")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
// Web UI / API
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface & port for UI")
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert")
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set 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")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-require-starttls", config.SMTPRequireSTARTTLS, "Require SMTP client use STARTTLS")
rootCmd.Flags().BoolVar(&config.SMTPRequireTLS, "smtp-require-tls", config.SMTPRequireTLS, "Require client use SSL/TLS")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Allow insecure PLAIN & LOGIN SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
rootCmd.Flags().BoolVar(&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
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP relay configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
// SMTP forwarding
rootCmd.Flags().StringVar(&config.SMTPForwardConfigFile, "smtp-forward-config", config.SMTPForwardConfigFile, "SMTP forwarding configuration file for all messages")
// Chaos
rootCmd.Flags().BoolVar(&chaos.Enabled, "enable-chaos", chaos.Enabled, "Enable Chaos functionality (API / web UI)")
rootCmd.Flags().StringVar(&config.ChaosTriggers, "chaos-triggers", config.ChaosTriggers, "Enable Chaos & set the triggers for SMTP server")
// POP3 server
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
rootCmd.Flags().StringVar(&config.POP3TLSCert, "pop3-tls-cert", config.POP3TLSCert, "Optional TLS certificate for POP3 server - requires pop3-tls-key")
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")
// Tagging
rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
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")
rootCmd.Flags().Lookup("db-file").Hidden = true
// DEPRECATED FLAGS 2023/03/12
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-ssl-cert", config.SMTPTLSCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-ssl-key", config.SMTPTLSKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().Lookup("ui-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-cert").Deprecated = "use --ui-tls-cert"
rootCmd.Flags().Lookup("ui-ssl-key").Hidden = true
rootCmd.Flags().Lookup("ui-ssl-key").Deprecated = "use --ui-tls-key"
rootCmd.Flags().Lookup("smtp-ssl-cert").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-cert").Deprecated = "use --smtp-tls-cert"
rootCmd.Flags().Lookup("smtp-ssl-key").Hidden = true
rootCmd.Flags().Lookup("smtp-ssl-key").Deprecated = "use --smtp-tls-key"
// DEPRECATED FLAGS 2024/03/16
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-tls-required", config.SMTPRequireSTARTTLS, "smtp-require-starttls")
rootCmd.Flags().Lookup("smtp-tls-required").Hidden = true
rootCmd.Flags().Lookup("smtp-tls-required").Deprecated = "use --smtp-require-starttls"
// DEPRECATED FLAG 2024/04/13 - no longer used
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
rootCmd.Flags().Lookup("disable-html-check").Hidden = true
}
// Load settings from environment
func initConfigFromEnv() {
// General
if len(os.Getenv("MP_DATABASE")) > 0 {
config.Database = os.Getenv("MP_DATABASE")
// defaults from envars if provided
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.DataFile = os.Getenv("MP_DATA_FILE")
}
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"))
}
config.TenantID = os.Getenv("MP_TENANT_ID")
config.Label = os.Getenv("MP_LABEL")
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_MAX_AGE")) > 0 {
config.MaxAge = os.Getenv("MP_MAX_AGE")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
config.IgnoreDuplicateIDs = true
}
if len(os.Getenv("MP_LOG_FILE")) > 0 {
logger.LogFile = os.Getenv("MP_LOG_FILE")
}
if getEnabledFromEnv("MP_QUIET") {
logger.QuietLogging = true
}
if getEnabledFromEnv("MP_VERBOSE") {
logger.VerboseLogging = true
}
// Web UI & API
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
if err := auth.SetUIAuth(os.Getenv("MP_UI_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if getEnabledFromEnv("MP_ALLOW_INTERNAL_HTTP_REQUESTS") {
config.AllowInternalHTTPRequests = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
config.AllowUntrustedTLS = true
}
if 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 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
if err := auth.SetSMTPAuth(os.Getenv("MP_SMTP_AUTH")); err != nil {
logger.Log().Error(err.Error())
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
if getEnabledFromEnv("MP_SMTP_REQUIRE_STARTTLS") {
config.SMTPRequireSTARTTLS = true
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
}
if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") {
config.SMTPRequireTLS = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if getEnabledFromEnv("MP_SMTP_STRICT_RFC_HEADERS") {
config.SMTPStrictRFCHeaders = true
}
if len(os.Getenv("MP_SMTP_MAX_RECIPIENTS")) > 0 {
config.SMTPMaxRecipients, _ = strconv.Atoi(os.Getenv("MP_SMTP_MAX_RECIPIENTS"))
}
if len(os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")) > 0 {
config.SMTPAllowedRecipients = os.Getenv("MP_SMTP_ALLOWED_RECIPIENTS")
}
if getEnabledFromEnv("MP_SMTP_IGNORE_REJECTED_RECIPIENTS") {
config.SMTPIgnoreRejectedRecipients = true
}
if getEnabledFromEnv("MP_SMTP_DISABLE_RDNS") {
smtpd.DisableReverseDNS = true
}
// SMTP relay
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAll = true
}
config.SMTPRelayMatching = os.Getenv("MP_SMTP_RELAY_MATCHING")
config.SMTPRelayConfig = config.SMTPRelayConfigStruct{}
config.SMTPRelayConfig.Host = os.Getenv("MP_SMTP_RELAY_HOST")
if len(os.Getenv("MP_SMTP_RELAY_PORT")) > 0 {
config.SMTPRelayConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_RELAY_PORT"))
}
config.SMTPRelayConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_RELAY_STARTTLS")
config.SMTPRelayConfig.TLS = getEnabledFromEnv("MP_SMTP_RELAY_TLS")
config.SMTPRelayConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
config.SMTPRelayConfig.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")
config.SMTPForwardConfig = config.SMTPForwardConfigStruct{}
config.SMTPForwardConfig.Host = os.Getenv("MP_SMTP_FORWARD_HOST")
if len(os.Getenv("MP_SMTP_FORWARD_PORT")) > 0 {
config.SMTPForwardConfig.Port, _ = strconv.Atoi(os.Getenv("MP_SMTP_FORWARD_PORT"))
}
config.SMTPForwardConfig.STARTTLS = getEnabledFromEnv("MP_SMTP_FORWARD_STARTTLS")
config.SMTPForwardConfig.TLS = getEnabledFromEnv("MP_SMTP_FORWARD_TLS")
config.SMTPForwardConfig.AllowInsecure = getEnabledFromEnv("MP_SMTP_FORWARD_ALLOW_INSECURE")
config.SMTPForwardConfig.Auth = os.Getenv("MP_SMTP_FORWARD_AUTH")
config.SMTPForwardConfig.Username = os.Getenv("MP_SMTP_FORWARD_USERNAME")
config.SMTPForwardConfig.Password = os.Getenv("MP_SMTP_FORWARD_PASSWORD")
config.SMTPForwardConfig.Secret = os.Getenv("MP_SMTP_FORWARD_SECRET")
config.SMTPForwardConfig.ReturnPath = os.Getenv("MP_SMTP_FORWARD_RETURN_PATH")
config.SMTPForwardConfig.OverrideFrom = os.Getenv("MP_SMTP_FORWARD_OVERRIDE_FROM")
config.SMTPForwardConfig.To = os.Getenv("MP_SMTP_FORWARD_TO")
config.SMTPForwardConfig.ForwardSMTPErrors = getEnabledFromEnv("MP_SMTP_FORWARD_FWD_SMTP_ERRORS")
// Chaos
chaos.Enabled = getEnabledFromEnv("MP_ENABLE_CHAOS")
config.ChaosTriggers = os.Getenv("MP_CHAOS_TRIGGERS")
// POP3 server
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")
}
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
logger.Log().Error(err.Error())
}
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
// Tagging
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
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 {
config.WebhookURL = os.Getenv("MP_WEBHOOK_URL")
}
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")
}
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
if len(os.Getenv("MP_DATA_FILE")) > 0 {
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")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
}
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
config.SMTPSSLCert = os.Getenv("MP_SMTP_SSL_CERT")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
logger.Log().Warn("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
}
// deprecated 2023/12/10
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
logger.Log().Warn("ENV MP_STRICT_RFC_HEADERS has been deprecated, use MP_SMTP_STRICT_RFC_HEADERS")
config.SMTPStrictRFCHeaders = true
}
// deprecated 2024/03.16
if getEnabledFromEnv("MP_SMTP_TLS_REQUIRED") {
logger.Log().Warn("ENV MP_SMTP_TLS_REQUIRED has been deprecated, use MP_SMTP_REQUIRE_STARTTLS")
config.SMTPRequireSTARTTLS = true
}
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
logger.Log().Warn("ENV MP_DISABLE_HTML_CHECK has been deprecated and is no longer used")
config.DisableHTMLCheck = true
}
}
// Wrapper to get a boolean from an environment variable
func getEnabledFromEnv(k string) bool {
if len(os.Getenv(k)) > 0 {
v := strings.ToLower(os.Getenv(k))
return v == "1" || v == "true" || v == "yes"
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
}
return false
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_SSL_CERT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_SSL_KEY")
}
// deprecated 2022/08/28
if len(os.Getenv("MP_DATA_DIR")) > 0 {
fmt.Println("MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
config.DataFile = os.Getenv("MP_DATA_DIR")
}
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ui-ssl-key", config.UISSLKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// deprecated 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ssl-cert", config.UISSLCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ssl-key", config.UISSLKey, "SSL key - requires ssl-cert")
rootCmd.Flags().Lookup("auth-file").Hidden = true
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-ssl-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-ssl-key"
// deprecated 2022/08/30
rootCmd.Flags().StringVar(&config.DataFile, "data", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().Lookup("data").Hidden = true
rootCmd.Flags().Lookup("data").Deprecated = "use --db-file"
}

View File

@@ -1,16 +1,22 @@
package cmd
import (
"os"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
smtpAddr = "localhost:1025"
fromAddr string
)
// sendmailCmd represents the sendmail command
var sendmailCmd = &cobra.Command{
Use: "sendmail [flags] [recipients]",
Short: "A sendmail command replacement for Mailpit",
Use: "sendmail",
Short: "A sendmail command replacement",
Long: `A sendmail command replacement.
You can optionally create a symlink called 'sendmail' to the main binary.`,
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
@@ -18,22 +24,10 @@ var sendmailCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(sendmailCmd)
var ignored string
// print out manual help screen
sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"}))
// these are simply repeated for cli consistency as cobra/viper does not allow
// multi-letter single-dash variables (-bs)
sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender")
sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "ignored-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "ignored-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("ignored-i", "i", false, "Ignored")
sendmailCmd.Flags().BoolP("ignored-o", "o", false, "Ignored")
sendmailCmd.Flags().BoolP("ignored-t", "t", false, "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-name", "F", "", "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-bits", "B", "", "Ignored")
sendmailCmd.Flags().StringVarP(&ignored, "ignored-errors", "e", "", "Ignored")
// these are simply repeated for cli consistency
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", "", "SMTP sender")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
}

View File

@@ -5,53 +5,49 @@ import (
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/updater"
"github.com/spf13/cobra"
)
var (
// 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"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Display the current version & update information",
Long: `Display the current version & update information (if available).`,
Run: func(cmd *cobra.Command, _ []string) {
RunE: func(cmd *cobra.Command, args []string) error {
updater.AllowPrereleases = true
update, _ := cmd.Flags().GetBool("update")
noReleaseCheck, _ := cmd.Flags().GetBool("no-release-check")
if update {
// 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)
return updateApp()
}
fmt.Printf("%s %s compiled with %s on %s/%s\n",
os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
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
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
if err == nil && updater.GreaterThan(latest, Version) {
fmt.Printf(
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
release.Tag,
latest,
os.Args[0],
)
}
return nil
},
}
@@ -60,6 +56,14 @@ func init() {
versionCmd.Flags().
BoolP("update", "u", false, "update to latest version")
versionCmd.Flags().
Bool("no-release-check", false, "do not check online for the latest release version")
}
func updateApp() error {
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
if err != nil {
return err
}
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
return nil
}

View File

@@ -1,653 +1,151 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"net"
"os"
"path"
"path/filepath"
"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"
"github.com/tg123/go-htpasswd"
)
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"
SMTPListen = "0.0.0.0:1025"
// HTTPListen to listen on <interface>:<port>
HTTPListen = "[::]:8025"
HTTPListen = "0.0.0.0:8025"
// Database for mail (optional)
Database string
// DisableWAL will disable Write-Ahead Logging in SQLite
// @see https://sqlite.org/wal.html
DisableWAL bool
// Compression is the compression level used to store raw messages in the database:
// 0 = off, 1 = fastest (default), 2 = standard, 3 = best compression
Compression = 1
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID string
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label string
// DataFile for mail (optional)
DataFile string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// MaxAge is the maximum age of messages (auto-pruned every hour).
// Value can be either <int>h for hours or <int>d for days
MaxAge string
// VerboseLogging for console output
VerboseLogging = false
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
MaxAgeInHours int
// QuietLogging for console output (errors only)
QuietLogging = false
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
// NoLogging for tests
NoLogging = false
// UITLSCert file
UITLSCert string
// UISSLCert file
UISSLCert string
// UITLSKey file
UITLSKey string
// UISSLKey file
UISSLKey string
// UIAuthFile for UI & API authentication
// UIAuthFile for basic authentication
UIAuthFile string
// Webroot to define the base path for the UI and API
Webroot = "/"
// UIAuth used for euthentication
UIAuth *htpasswd.File
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
DisableHTTPCompression bool
// SMTPSSLCert file
SMTPSSLCert string
// 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
// SMTPTLSKey file
SMTPTLSKey string
// SMTPRequireSTARTTLS to enforce the use of STARTTLS
// The only allowed commands are NOOP, EHLO, STARTTLS and QUIT (as specified in RFC 3207) until
// the connection is upgraded to TLS i.e. until STARTTLS is issued.
SMTPRequireSTARTTLS bool
// SMTPRequireTLS to allow only SSL/TLS connections for all connections
//
SMTPRequireTLS bool
// SMTPSSLKey file
SMTPSSLKey string
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
// SMTPAuthAcceptAny accepts any username/password including none
SMTPAuthAcceptAny bool
// SMTPMaxRecipients is the maximum number of recipients a message may have.
// The SMTP RFC states that an server must handle a minimum of 100 recipients
// however some servers accept more.
SMTPMaxRecipients = 100
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
// 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,100}$`)
// TagsConfig is a yaml file to pre-load tags
TagsConfig string
// TagFilters are used to apply tags to new mail
TagFilters []autoTag
// TagsDisable accepts a comma-separated list of tag types to disable
// 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
// SMTPRelayConfig to parse a yaml file and store config of the the relay SMTP server
SMTPRelayConfig SMTPRelayConfigStruct
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// SMTPRelayAll is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAll = false
// SMTPRelayMatching if set, will auto-release to recipients matching this regular expression
SMTPRelayMatching string
// SMTPRelayMatchingRegexp is the compiled version of SMTPRelayMatching
SMTPRelayMatchingRegexp *regexp.Regexp
// SMTPForwardConfigFile to parse a yaml file and store config of the forwarding SMTP server
SMTPForwardConfigFile string
// SMTPForwardConfig to parse a yaml file and store config of the forwarding SMTP server
SMTPForwardConfig SMTPForwardConfigStruct
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
SMTPStrictRFCHeaders bool
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
SMTPAllowedRecipients string
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
SMTPAllowedRecipientsRegexp *regexp.Regexp
// 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"
// POP3AuthFile for POP3 authentication
POP3AuthFile string
// POP3TLSCert TLS certificate
POP3TLSCert string
// POP3TLSKey TLS certificate key
POP3TLSKey string
// EnableSpamAssassin must be either <host>:<port> or "postmark"
EnableSpamAssassin string
// HideDeleteAllButton hides the delete all button in the web UI
HideDeleteAllButton bool
// WebhookURL for calling
WebhookURL string
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
ContentSecurityPolicy string
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
AllowUntrustedTLS bool
// 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
// 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
// SMTPAuth used for euthentication
SMTPAuth *htpasswd.File
)
// AutoTag struct for auto-tagging
type autoTag struct {
Match string
Tags []string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
type SMTPRelayConfigStruct struct {
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
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
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"`
}
// 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
ForwardSMTPErrors bool `yaml:"forward-smtp-errors"` // whether to log smtp-errors or forward them to upstream-client
}
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
cssFontRestriction := "*"
if BlockRemoteCSSAndFonts {
cssFontRestriction = "'self'"
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
}
// 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';",
cssFontRestriction, cssFontRestriction,
)
if Database != "" && isDir(Database) {
Database = filepath.Join(Database, "mailpit.db")
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
}
if !re.MatchString(HTTPListen) {
return errors.New("HTTP bind should be in the format of <ip>:<port>")
}
if Compression < 0 || Compression > 3 {
return errors.New("[db] compression level must be between 0 and 3")
}
Label = tools.Normalize(Label)
if err := parseMaxAge(); err != nil {
return err
}
TenantID = DBTenantID(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
}
re := regexp.MustCompile(`.*:\d+$`)
if _, _, isSocket := tools.UnixSocket(SMTPListen); !isSocket && !re.MatchString(SMTPListen) {
return errors.New("[smtp] bind should be in the format of <ip>:<port>")
}
if _, _, isSocket := tools.UnixSocket(HTTPListen); !isSocket && !re.MatchString(HTTPListen) {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
// Web UI & API
if UIAuthFile != "" {
UIAuthFile = filepath.Clean(UIAuthFile)
if !isFile(UIAuthFile) {
return fmt.Errorf("[ui] HTTP password file not found or readable: %s", UIAuthFile)
return fmt.Errorf("HTTP password file not found: %s", UIAuthFile)
}
b, err := os.ReadFile(UIAuthFile)
a, err := htpasswd.New(UIAuthFile, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
UIAuth = a
}
if err := auth.SetUIAuth(string(b)); err != nil {
return err
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
return errors.New("you must provide both a UI SSL certificate and a key")
}
if UISSLCert != "" {
if !isFile(UISSLCert) {
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
}
if !isFile(UISSLKey) {
return fmt.Errorf("SSL key not found: %s", UISSLKey)
}
}
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
return errors.New("[ui] you must provide both a UI TLS certificate and a key")
if SMTPSSLCert != "" && SMTPSSLKey == "" || SMTPSSLCert == "" && SMTPSSLKey != "" {
return errors.New("you must provide both an SMTP SSL certificate and a key")
}
if UITLSCert != "" {
if strings.HasPrefix(UITLSCert, "sans:") {
// generate a self-signed certificate
UITLSCert = snakeoil.Public(UITLSCert)
} else {
UITLSCert = filepath.Clean(UITLSCert)
if SMTPSSLCert != "" {
if !isFile(SMTPSSLCert) {
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
}
if strings.HasPrefix(UITLSKey, "sans:") {
// generate a self-signed key
UITLSKey = snakeoil.Private(UITLSKey)
} else {
UITLSKey = filepath.Clean(UITLSKey)
if !isFile(SMTPSSLKey) {
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
}
if !isFile(UITLSCert) {
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("[ui] TLS key not found or readable: %s", UITLSKey)
}
}
// 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 != "" {
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)
}
if !isFile(SMTPTLSKey) {
return fmt.Errorf("[smtp] TLS key not found or readable: %s", SMTPTLSKey)
}
} else if SMTPRequireTLS {
return errors.New("[smtp] TLS cannot be required without an SMTP TLS certificate and key")
} else if SMTPRequireSTARTTLS {
return errors.New("[smtp] STARTTLS cannot be required without an SMTP TLS certificate and key")
}
if SMTPRequireSTARTTLS && SMTPAuthAllowInsecure || SMTPRequireTLS && SMTPAuthAllowInsecure {
return errors.New("[smtp] TLS cannot be required with --smtp-auth-allow-insecure")
}
if SMTPRequireSTARTTLS && SMTPRequireTLS {
return errors.New("[smtp] TLS & STARTTLS cannot be required together")
}
if SMTPAuthFile != "" {
SMTPAuthFile = filepath.Clean(SMTPAuthFile)
if !isFile(SMTPAuthFile) {
return fmt.Errorf("[smtp] password file not found or readable: %s", SMTPAuthFile)
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
b, err := os.ReadFile(SMTPAuthFile)
if SMTPSSLCert == "" {
return errors.New("SMTP authentication requires SMTP encryption")
}
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
if err := auth.SetSMTPAuth(string(b)); err != nil {
return err
}
if !SMTPAuthAllowInsecure {
// https://www.rfc-editor.org/rfc/rfc4954
// A server implementation MUST implement a configuration in which
// it does NOT permit any plaintext password mechanisms, unless either
// the STARTTLS [SMTP-TLS] command has been negotiated or some other
// mechanism that protects the session from password snooping has been
// provided. Server sites SHOULD NOT use any configuration which
// permits a plaintext password mechanism without such a protection
// mechanism against password snooping.
SMTPRequireSTARTTLS = true
}
}
if auth.SMTPCredentials != nil && SMTPAuthAcceptAny {
return errors.New("[smtp] authentication cannot use both credentials and --smtp-auth-accept-any")
}
if SMTPTLSCert == "" && (auth.SMTPCredentials != nil || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("[smtp] authentication requires STARTTLS or TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
if err := parseChaosTriggers(); err != nil {
return fmt.Errorf("[chaos] %s", err.Error())
}
if chaos.Enabled {
logger.Log().Info("[chaos] is enabled")
}
// POP3 server
if POP3TLSCert != "" {
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)
}
if !isFile(POP3TLSKey) {
return fmt.Errorf("[pop3] TLS key not found or readable: %s", POP3TLSKey)
}
}
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
return errors.New("[pop3] you must provide both a POP3 TLS certificate and a key")
}
if POP3Listen != "" {
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
if err != nil {
return fmt.Errorf("[pop3] %s", err.Error())
}
}
if POP3AuthFile != "" {
POP3AuthFile = filepath.Clean(POP3AuthFile)
if !isFile(POP3AuthFile) {
return fmt.Errorf("[pop3] password file not found or readable: %s", POP3AuthFile)
}
b, err := os.ReadFile(POP3AuthFile)
if err != nil {
return err
}
if err := auth.SetPOP3Auth(string(b)); err != nil {
return err
}
}
// Web root
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.@]`)
if validWebrootRe.MatchString(Webroot) {
return fmt.Errorf("invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]", Webroot)
}
s := strings.TrimRight(path.Join("/", Webroot, "/"), "/") + "/"
Webroot = s
if WebhookURL != "" && !isValidURL(WebhookURL) {
return fmt.Errorf("webhook URL does not appear to be a valid URL (%s)", WebhookURL)
}
// DEPRECATED 2024/04/13
if DisableHTMLCheck {
logger.Log().Warn("--disable-html-check has been deprecated and is no longer used")
}
if EnableSpamAssassin != "" {
spamassassin.SetService(EnableSpamAssassin)
logger.Log().Infof("[spamassassin] enabled via %s", EnableSpamAssassin)
if err := spamassassin.Ping(); err != nil {
logger.Log().Warnf("[spamassassin] ping: %s", err.Error())
}
}
// load tag filters & options
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
}
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}
if err := parseTagsDisable(TagsDisable); err != nil {
return err
}
if SMTPAllowedRecipients != "" {
restrictRegexp, err := regexp.Compile(SMTPAllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile smtp-allowed-recipients regexp: %s", err.Error())
}
SMTPAllowedRecipientsRegexp = restrictRegexp
logger.Log().Infof("[smtp] only allowing recipients matching 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
}
// separate relay config validation to account for environment variables
if err := validateRelayConfig(); err != nil {
return err
}
if !ReleaseEnabled && SMTPRelayAll || !ReleaseEnabled && SMTPRelayMatching != "" {
return errors.New("[relay] a relay configuration must be set to auto-relay any messages")
}
if SMTPRelayMatching != "" {
if SMTPRelayAll {
logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled")
} else {
re, err := regexp.Compile(SMTPRelayMatching)
if err != nil {
return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error())
}
SMTPRelayMatchingRegexp = re
logger.Log().Infof(
"[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d",
SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port,
)
}
}
if SMTPRelayAll {
// this deserves a warning
logger.Log().Warnf("[relay] auto-relaying all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
if err := parseForwardConfig(SMTPForwardConfigFile); err != nil {
return err
}
// separate forwarding config validation to account for environment variables
if err := validateForwardConfig(); err != nil {
return err
}
if DemoMode {
MaxMessages = 1000
// this deserves a warning
logger.Log().Info("demo mode enabled")
SMTPAuth = a
}
return nil
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// IsDir returns whether 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
}

View File

@@ -1,111 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/goccy/go-yaml"
)
var (
// TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig()
TagsDisablePlus bool
// TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig()
TagsDisableXTags bool
)
type yamlTags struct {
Filters []yamlTag `yaml:"filters"`
}
type yamlTag struct {
Match string `yaml:"match"`
Tags string `yaml:"tags"`
}
// Load tags from a configuration from a file, if set
func loadTagsFromConfig(c string) error {
if c == "" {
return nil // not set, ignore
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return fmt.Errorf("[tags] %s", err.Error())
}
conf := yamlTags{}
if err := yaml.Unmarshal(data, &conf); err != nil {
return err
}
if conf.Filters == nil {
return fmt.Errorf("[tags] missing tag: array in %s", c)
}
for _, t := range conf.Filters {
tags := strings.Split(t.Tags, ",")
TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})
}
logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c)
return nil
}
func loadTagsFromArgs(c string) error {
if c == "" {
return nil // not set, ignore
}
args := tools.ArgsParser(c)
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
tags := strings.Split(t[0], ",")
TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters"))
return nil
}
func parseTagsDisable(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.SplitSeq(strings.ToLower(s), ",")
for p := range parts {
switch strings.TrimSpace(p) {
case "x-tags", "xtags":
TagsDisableXTags = true
case "plus-addresses", "plus-addressing":
TagsDisablePlus = true
default:
return fmt.Errorf("[tags] invalid --tags-disable option: %s", p)
}
}
return nil
}

View File

@@ -1,51 +0,0 @@
package config
import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/axllent/mailpit/internal/tools"
)
// IsFile returns whether a file exists and is readable
func isFile(path string) bool {
f, err := os.Open(filepath.Clean(path))
defer func() { _ = f.Close() }()
return err == nil
}
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil || os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}
func isValidURL(s string) bool {
u, err := url.ParseRequestURI(s)
if err != nil {
return false
}
return strings.HasPrefix(u.Scheme, "http")
}
// DBTenantID converts a tenant ID to a DB-friendly value if set
func DBTenantID(s string) string {
s = tools.Normalize(s)
if s != "" {
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
s = re.ReplaceAllString(s, "_")
if !strings.HasSuffix(s, "_") {
s = s + "_"
}
}
return s
}

View File

@@ -1,290 +0,0 @@
package config
import (
"errors"
"fmt"
"net/mail"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/smtpd/chaos"
"github.com/goccy/go-yaml"
)
// Parse the --max-age value (if set)
func parseMaxAge() error {
if MaxAge == "" {
return nil
}
re := regexp.MustCompile(`^\d+(h|d)$`)
if !re.MatchString(MaxAge) {
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
}
if before, ok := strings.CutSuffix(MaxAge, "h"); ok {
hours, err := strconv.Atoi(before)
if err != nil {
return err
}
MaxAgeInHours = hours
return nil
}
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
if err != nil {
return err
}
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
MaxAgeInHours = days * 24
return nil
}
// Parse the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[relay] configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("[relay] host not set")
}
// DEPRECATED 2024/03/12
if SMTPRelayConfig.RecipientAllowlist != "" {
logger.Log().Warn("[relay] 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
if SMTPRelayConfig.AllowedRecipients == "" {
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
}
}
return nil
}
// Validate the SMTPRelayConfig (if Host is set)
func validateRelayConfig() error {
if SMTPRelayConfig.Host == "" {
return nil
}
if SMTPRelayConfig.Port == 0 {
SMTPRelayConfig.Port = 25 // default
}
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
SMTPRelayConfig.Auth = "none"
} else if SMTPRelayConfig.Auth == "plain" {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[relay] host username or password not set for PLAIN authentication")
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[relay] host username or password not set for LOGIN authentication")
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
return fmt.Errorf("[relay] host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[relay] authentication method not supported: %s", SMTPRelayConfig.Auth)
}
if SMTPRelayConfig.AllowedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[relay] failed to compile recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = re
logger.Log().Infof("[relay] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
}
if SMTPRelayConfig.BlockedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
if err != nil {
return fmt.Errorf("[relay] failed to compile recipient blocklist regexp: %s", err.Error())
}
SMTPRelayConfig.BlockedRecipientsRegexp = re
logger.Log().Infof("[relay] recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
}
if SMTPRelayConfig.OverrideFrom != "" {
m, err := mail.ParseAddress(SMTPRelayConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("[relay] override-from is not a valid email address: %s", SMTPRelayConfig.OverrideFrom)
}
SMTPRelayConfig.OverrideFrom = m.Address
}
if SMTPRelayConfig.STARTTLS && SMTPRelayConfig.TLS {
return fmt.Errorf("[relay] TLS & STARTTLS cannot be required together")
}
ReleaseEnabled = true
logger.Log().Infof("[relay] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
return nil
}
// Parse the SMTPForwardConfigFile (if set)
func parseForwardConfig(c string) error {
if c == "" {
return nil
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[forward] configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPForwardConfig); err != nil {
return err
}
if SMTPForwardConfig.Host == "" {
return errors.New("[forward] host not set")
}
return nil
}
// Validate the SMTPForwardConfig (if Host is set)
func validateForwardConfig() error {
if SMTPForwardConfig.Host == "" {
return nil
}
if SMTPForwardConfig.Port == 0 {
SMTPForwardConfig.Port = 25 // default
}
SMTPForwardConfig.Auth = strings.ToLower(SMTPForwardConfig.Auth)
if SMTPForwardConfig.Auth == "" || SMTPForwardConfig.Auth == "none" || SMTPForwardConfig.Auth == "false" {
SMTPForwardConfig.Auth = "none"
} else if SMTPForwardConfig.Auth == "plain" {
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
return fmt.Errorf("[forward] host username or password not set for PLAIN authentication")
}
} else if SMTPForwardConfig.Auth == "login" {
SMTPForwardConfig.Auth = "login"
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Password == "" {
return fmt.Errorf("[forward] host username or password not set for LOGIN authentication")
}
} else if strings.HasPrefix(SMTPForwardConfig.Auth, "cram") {
SMTPForwardConfig.Auth = "cram-md5"
if SMTPForwardConfig.Username == "" || SMTPForwardConfig.Secret == "" {
return fmt.Errorf("[forward] host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[forward] authentication method not supported: %s", SMTPForwardConfig.Auth)
}
if SMTPForwardConfig.To == "" {
return errors.New("[forward] To addresses missing")
}
to := []string{}
addresses := strings.SplitSeq(SMTPForwardConfig.To, ",")
for a := range addresses {
a = strings.TrimSpace(a)
m, err := mail.ParseAddress(a)
if err != nil {
return fmt.Errorf("[forward] To address is not a valid email address: %s", a)
}
to = append(to, m.Address)
}
if len(to) == 0 {
return errors.New("[forward] no valid To addresses found")
}
// overwrite the To field with the cleaned up list
SMTPForwardConfig.To = strings.Join(to, ",")
if SMTPForwardConfig.OverrideFrom != "" {
m, err := mail.ParseAddress(SMTPForwardConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("[forward] override-from is not a valid email address: %s", SMTPForwardConfig.OverrideFrom)
}
SMTPForwardConfig.OverrideFrom = m.Address
}
if SMTPForwardConfig.STARTTLS && SMTPForwardConfig.TLS {
return fmt.Errorf("[forward] TLS & STARTTLS cannot be required together")
}
logger.Log().Infof("[forward] enabling message forwarding to %s via %s:%d", SMTPForwardConfig.To, SMTPForwardConfig.Host, SMTPForwardConfig.Port)
return nil
}
func parseChaosTriggers() error {
if ChaosTriggers == "" {
return nil
}
re := regexp.MustCompile(`^([a-zA-Z0-0]+):(\d\d\d):(\d+(\.\d)?)$`)
parts := strings.SplitSeq(ChaosTriggers, ",")
for p := range parts {
p = strings.TrimSpace(p)
if !re.MatchString(p) {
return fmt.Errorf("invalid argument: %s", p)
}
matches := re.FindAllStringSubmatch(p, 1)
key := matches[0][1]
errorCode, err := strconv.Atoi(matches[0][2])
if err != nil {
return err
}
probability, err := strconv.Atoi(matches[0][3])
if err != nil {
return err
}
if err := chaos.Set(key, errorCode, probability); err != nil {
return err
}
}
return nil
}

24
data/mailbox.go Normal file
View File

@@ -0,0 +1,24 @@
package data
import "time"
// MailboxSummary struct
type MailboxSummary struct {
Name string
Slug string
Total int
Unread int
LastMessage time.Time
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
}

65
data/message.go Normal file
View File

@@ -0,0 +1,65 @@
// Package data contains the message & mailbox structs
package data
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message struct for loading messages. It does not include physical attachments.
type Message struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Date time.Time
Text string
HTML string
HTMLSource string
Size int
Inline []Attachment
Attachments []Attachment
}
// Attachment struct for inline and attachments
type Attachment struct {
PartID string
FileName string
ContentType string
ContentID string
Size int
}
// Summary struct for frontend messages
type Summary struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Created time.Time
Size int
Attachments int
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
return o
}

22
esbuild.config.js Normal file
View File

@@ -0,0 +1,22 @@
const { build } = require('esbuild')
const pluginVue = require('esbuild-plugin-vue-next')
const { sassPlugin } = require('esbuild-sass-plugin');
const doWatch = process.env.WATCH == 'true' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;
build({
entryPoints: ["server/ui-src/app.js"],
bundle: true,
watch: doWatch,
minify: doMinify,
sourcemap: false,
outfile: "server/ui/dist/app.js",
plugins: [pluginVue(), sassPlugin()],
loader: {
".svg": "file",
".woff": "file",
".woff2": "file",
},
logLevel: "info"
})

View File

@@ -1,39 +0,0 @@
import * as esbuild from "esbuild";
import pluginVue from "esbuild-plugin-vue-next";
import { sassPlugin } from "esbuild-sass-plugin";
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",
});
if (doWatch) {
await ctx.watch();
} else {
await ctx.rebuild();
ctx.dispose();
}

View File

@@ -1,76 +0,0 @@
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",
},
},
];

118
go.mod
View File

@@ -1,79 +1,63 @@
module github.com/axllent/mailpit
go 1.25.0
go 1.18
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
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/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/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.32.0
golang.org/x/crypto v0.48.0
golang.org/x/net v0.51.0
golang.org/x/text v0.34.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.46.1
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.10.0
github.com/k3a/html2text v1.0.8
github.com/klauspost/compress v1.15.9
github.com/leporo/sqlf v1.3.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.0
golang.org/x/text v0.3.7
modernc.org/sqlite v1.18.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/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // 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/cznic/ql v1.2.0 // 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/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.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.21 // 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.20.1 // indirect
github.com/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rivo/uniseg v0.3.4 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.7.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/tools v0.1.12 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.3 // indirect
modernc.org/ccgo/v3 v3.16.9 // indirect
modernc.org/libc v1.17.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.2.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

420
go.sum
View File

@@ -1,269 +1,225 @@
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.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/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/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/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/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
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-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=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.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/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/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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/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/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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leporo/sqlf v1.3.0 h1:nAkuPYxMIJg/sUmcd1h4avV5iYo8tBTGEGOIR4BIZO8=
github.com/leporo/sqlf v1.3.0/go.mod h1:f4dHqIi1+nLl6k1IsNQ8QIEbGWK49th2ei1IxTXk+2E=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw=
github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/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.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/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.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/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
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.32.0 h1:dW1y2IKSBQyYIwMc9comDA2e+00/pJ1kVXf3v4sqAJo=
github.com/vanng822/go-premailer v1.32.0/go.mod h1:4gVC6Hs+ESjSSfB1ohMwLqwuGoJ76cc0c2VM7DYqr0s=
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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
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.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=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
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.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.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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=

View File

@@ -1,214 +1,98 @@
#!/bin/sh
# This script will install the latest release of Mailpit.
# Check dependencies is installed
for cmd in curl tar; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Then $cmd command is required but not installed."
echo "Please install $cmd and try again."
exit 1
fi
done
# Check if the OS is supported.
OS=
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*)
echo "OS not supported."
exit 2
;;
esac
# Detect the architecture of the OS.
OS_ARCH=
case "$(uname -m)" in
x86_64 | amd64)
OS_ARCH="amd64"
;;
i?86 | x86)
OS_ARCH="386"
;;
aarch64 | arm64)
OS_ARCH="arm64"
;;
*)
echo "OS architecture not supported."
exit 2
;;
esac
#!/usr/bin/env bash
GH_REPO="axllent/mailpit"
INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin}"
TIMEOUT=90
# This is used to authenticate with the GitHub API. (Fix the public rate limiting issue)
# Try the GITHUB_TOKEN environment variable is set globally.
GITHUB_API_TOKEN="${GITHUB_TOKEN:-}"
# Update the default values if the user has set.
while [ $# -gt 0 ]; do
case $1 in
--install-path)
shift
case "$1" in
*/*)
# Remove trailing slashes from the path.
INSTALL_PATH="$(echo "$1" | sed 's#/\+$##')"
[ -z "$INSTALL_PATH" ] && INSTALL_PATH="/"
;;
esac
;;
--auth | --auth-token | --github-token | --token)
shift
case "$1" in
gh*)
GITHUB_API_TOKEN="$1"
;;
esac
;;
*) ;;
esac
shift
done
set -e
# Description of the sort parameters for curl command.
# -s: Silent mode.
# -f: Fail silently on server errors.
# -L: Follow redirects.
# -m: Set maximum time allowed for the transfer.
if [ -n "$GITHUB_API_TOKEN" ] && [ "${#GITHUB_API_TOKEN}" -gt 36 ]; then
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT -H "Authorization: Bearer $GITHUB_API_TOKEN" https://api.github.com/repos/${GH_REPO}/releases/latest)"
EXIT_CODE=$?
else
CURL_OUTPUT="$(curl -sfL -m $TIMEOUT https://api.github.com/repos/${GH_REPO}/releases/latest)"
EXIT_CODE=$?
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ $? -ne 0 ]; then
echo -ne "\nThere was an error trying to check what is the latest version of ssbak.\nPlease try again later.\n"
exit 1
fi
VERSION=""
if [ $EXIT_CODE -eq 0 ]; then
# Extracts the latest version using jq, awk, or sed.
if command -v jq >/dev/null 2>&1; then
# Use jq -n because the output is not a valid JSON in sh.
VERSION=$(jq -n "$CURL_OUTPUT" | jq -r '.tag_name')
elif command -v awk >/dev/null 2>&1; then
VERSION=$(echo "$CURL_OUTPUT" | awk -F: '$1 ~ /tag_name/ {gsub(/[^v0-9\.]+/, "", $2) ;print $2; exit}')
elif command -v sed >/dev/null 2>&1; then
VERSION=$(echo "$CURL_OUTPUT" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
else
EXIT_CODE=3
fi
fi
# Validate the version.
case "$VERSION" in
v[0-9][0-9\.]*) ;;
# detect the platform
OS="$(uname)"
case $OS in
Linux)
OS='linux'
;;
FreeBSD)
OS='freebsd'
echo 'OS not supported'
exit 2
;;
NetBSD)
OS='netbsd'
echo 'OS not supported'
exit 2
;;
OpenBSD)
OS='openbsd'
echo 'OS not supported'
exit 2
;;
Darwin)
OS='darwin'
;;
SunOS)
OS='solaris'
echo 'OS not supported'
exit 2
;;
*)
echo "There was an error trying to check what is the latest version of Mailpit."
echo "Please try again later."
exit $EXIT_CODE
echo 'OS not supported'
exit 2
;;
esac
TEMP_DIR="$(mktemp -qd)"
EXIT_CODE=$?
# Ensure the temporary directory exists and is a directory.
if [ -z "$TEMP_DIR" ] || [ ! -d "$TEMP_DIR" ]; then
echo "ERROR: Creating temporary directory."
exit $EXIT_CODE
fi
# detect the arch
OS_type="$(uname -m)"
case "$OS_type" in
x86_64 | amd64)
OS_type='amd64'
;;
i?86 | x86)
OS_type='386'
;;
aarch64 | arm64)
OS_type='arm64'
;;
*)
echo 'OS type not supported'
exit 2
;;
esac
GH_REPO_BIN="mailpit-${OS}-${OS_ARCH}.tar.gz"
if [ "$INSTALL_PATH" = "/" ]; then
INSTALL_BIN_PATH="/mailpit"
else
INSTALL_BIN_PATH="${INSTALL_PATH}/mailpit"
fi
cd "$TEMP_DIR" || EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
# Download the latest release.
#
# Description of the sort parameters for curl command.
# -s: Silent mode.
# -f: Fail silently on server errors.
# -L: Follow redirects.
# -m: Set maximum time allowed for the transfer.
# -o: Write output to a file instead of stdout.
curl -sfL -m $TIMEOUT -o "${GH_REPO_BIN}" "https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
EXIT_CODE=$?
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
# The following conditions check each step of the installation.
# If there is an error in any of the steps, an error message is printed.
#create tmp directory and move to it with macOS compatibility fallback
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
cd "$tmp_dir"
if [ $EXIT_CODE -eq 0 ]; then
if ! [ -f "${GH_REPO_BIN}" ]; then
EXIT_CODE=1
echo "ERROR: Downloading latest release."
fi
fi
echo "Downloading Mailpit $VERSION"
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
if [ $EXIT_CODE -eq 0 ]; then
tar zxf "$GH_REPO_BIN"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Extracting \"${GH_REPO_BIN}\"."
fi
fi
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
echo "Error downloading"
exit 2
}
if [ $EXIT_CODE -eq 0 ] && [ ! -d "$INSTALL_PATH" ]; then
mkdir -p "${INSTALL_PATH}"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Creating \"${INSTALL_PATH}\" directory."
fi
fi
mkdir -p /usr/local/bin || exit 2
cp mailpit /usr/local/bin/ || exit 2
chmod 755 /usr/local/bin/mailpit || exit 2
case "$OS" in
'linux')
chown root:root /usr/local/bin/mailpit || exit 2
;;
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
chown root:wheel /usr/local/bin/mailpit || exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
if [ $EXIT_CODE -eq 0 ]; then
cp mailpit "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Copying mailpit to \"${INSTALL_PATH}\" directory."
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
chmod 755 "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Setting permissions for \"$INSTALL_BIN_PATH\" binary."
fi
fi
# Set the owner and group to root:root if the script is run as root.
if [ $EXIT_CODE -eq 0 ] && [ "$(id -u)" -eq "0" ]; then
OWNER="root"
GROUP="root"
# Set the OWNER, GROUP variable when the OS not use the default root:root.
case "$OS" in
darwin) GROUP="wheel" ;;
*) ;;
esac
chown "${OWNER}:${GROUP}" "$INSTALL_BIN_PATH"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Setting ownership for \"$INSTALL_BIN_PATH\" binary."
fi
fi
else
echo "ERROR: Changing to temporary directory."
exit $EXIT_CODE
fi
# Cleanup the temporary directory.
rm -rf "$TEMP_DIR"
# Check the EXIT_CODE variable, and print the success or error message.
if [ $EXIT_CODE -ne 0 ]; then
echo "There was an error installing Mailpit."
exit $EXIT_CODE
fi
echo "Installed successfully to \"$INSTALL_BIN_PATH\"."
exit 0
rm -rf "$tmp_dir"
echo "Installed successfully to /usr/local/bin/mailpit"

View File

@@ -1,111 +0,0 @@
// Package auth handles the web UI and SMTP authentication
package auth
import (
"regexp"
"strings"
"github.com/tg123/go-htpasswd"
)
var (
// UICredentials passwords
UICredentials *htpasswd.File
// SendAPICredentials passwords
SendAPICredentials *htpasswd.File
// SMTPCredentials passwords
SMTPCredentials *htpasswd.File
// POP3Credentials passwords
POP3Credentials *htpasswd.File
)
// SetUIAuth will set Basic Auth credentials required for the UI & API
func SetUIAuth(s string) error {
var err error
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
UICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
return nil
}
// 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
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
SMTPCredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
return nil
}
// SetPOP3Auth will set POP3 server credentials
func SetPOP3Auth(s string) error {
var err error
credentials := credentialsFromString(s)
if len(credentials) == 0 {
return nil
}
r := strings.NewReader(strings.Join(credentials, "\n"))
POP3Credentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
return nil
}
func credentialsFromString(s string) []string {
// split string by any whitespace character
re := regexp.MustCompile(`\s+`)
words := re.Split(s, -1)
credentials := []string{}
for _, w := range words {
if w != "" {
credentials = append(credentials, w)
}
}
return credentials
}

View File

@@ -1,163 +0,0 @@
// Package dump is used to export all messages from mailpit into a directory
package dump
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/apiv1"
)
var (
linkRe = regexp.MustCompile(`(?i)^https?:\/\/`)
outDir string
// Base URL of mailpit instance
base string
// URL is the base URL of a remove Mailpit instance
URL string
summary = []storage.MessageSummary{}
)
// Sync will sync all messages from the specified database or API to the specified output directory
func Sync(d string) error {
outDir = path.Clean(d)
if URL != "" {
if !linkRe.MatchString(URL) {
return errors.New("invalid URL")
}
base = strings.TrimRight(URL, "/") + "/"
}
if base == "" && config.Database == "" {
return errors.New("no database or API URL specified")
}
if !tools.IsDir(outDir) {
if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil {
return err
}
}
if err := loadIDs(); err != nil {
return err
}
if err := saveMessages(); err != nil {
return err
}
return nil
}
// LoadIDs will load all message IDs from the specified database or API
func loadIDs() error {
if base != "" {
// remote
logger.Log().Debugf("Fetching messages summary from %s", base)
res, err := http.Get(base + "api/v1/messages?limit=0")
if err != nil {
return err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
var data apiv1.MessagesSummary
if err := json.Unmarshal(body, &data); err != nil {
return err
}
summary = data.Messages
} else {
// make sure the database isn't pruned while open
config.MaxMessages = 0
var err error
// local database
if err = storage.InitDB(); err != nil {
return err
}
logger.Log().Debugf("Fetching messages summary from %s", config.Database)
summary, err = storage.List(0, 0, 0)
if err != nil {
return err
}
}
if len(summary) == 0 {
return errors.New("no messages found")
}
return nil
}
func saveMessages() error {
for _, m := range summary {
out := path.Join(outDir, m.ID+".eml")
// skip if message exists
if tools.IsFile(out) {
continue
}
var b []byte
if base != "" {
res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw")
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
continue
}
b, err = io.ReadAll(res.Body)
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
continue
}
} else {
var err error
b, err = storage.GetMessageRaw(m.ID)
if err != nil {
logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error())
continue
}
}
if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil {
logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error())
continue
}
_ = os.Chtimes(out, m.Created, m.Created)
logger.Log().Debugf("Saved message %s to %s", m.ID, out)
}
return nil
}

View File

@@ -1,83 +0,0 @@
// Package html2text is a simple library to convert HTML to plain text
package html2text
import (
"bytes"
"log"
"regexp"
"strings"
"unicode"
"golang.org/x/net/html"
)
var (
re = regexp.MustCompile(`\s+`)
spaceRe = regexp.MustCompile(`(?mi)<\/(div|p|td|th|h[1-6]|ul|ol|li|address|article|aside|blockquote|dl|dt|footer|header|hr|main|nav|pre|table|thead|tfoot|video)><`)
brRe = regexp.MustCompile(`(?mi)<(br /|br)>`)
imgRe = regexp.MustCompile(`(?mi)<(img)`)
skip = make(map[string]bool)
)
func init() {
skip["script"] = true
skip["title"] = true
skip["head"] = true
skip["link"] = true
skip["meta"] = true
skip["style"] = true
skip["noscript"] = true
}
// Strip will convert a HTML string to plain text
func Strip(h string, includeLinks bool) string {
h = spaceRe.ReplaceAllString(h, "</$1> <")
h = brRe.ReplaceAllString(h, " ")
h = imgRe.ReplaceAllString(h, " <$1")
var buffer bytes.Buffer
doc, err := html.Parse(strings.NewReader(h))
if err != nil {
log.Fatal(err)
}
extract(doc, &buffer, includeLinks)
return clean(buffer.String())
}
func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) {
if node.Type == html.TextNode {
data := node.Data
if data != "" {
buff.WriteString(data)
}
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
if _, skip := skip[c.Data]; !skip {
if includeLinks && c.Data == "a" {
for _, a := range c.Attr {
if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") {
buff.WriteString(" " + a.Val + " ")
}
}
}
extract(c, buff, includeLinks)
}
}
}
func clean(text string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
// remove non-printable characters
text = strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return []rune(" ")[0]
}, text)
text = re.ReplaceAllString(text, " ")
return strings.TrimSpace(text)
}

View File

@@ -1,250 +0,0 @@
package html2text
import "testing"
func TestPlain(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
tests["<h1>This is a test.</h1> "] = "This is a test."
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
for str, expected := range tests {
res := Strip(str, false)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()
}
}
}
func TestWithLinks(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["thiS IS a Test"] = "thiS IS a Test"
tests["thiS IS a Test :-)"] = "thiS IS a Test :-)"
tests["<h1>This is a test.</h1> "] = "This is a test."
tests["<p>Paragraph 1</p><p>Paragraph 2</p>"] = "Paragraph 1 Paragraph 2"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests["<span>Alpha</span>bet <strong>chars</strong>"] = "Alphabet chars"
tests["<span><b>A</b>lpha</span>bet chars."] = "Alphabet chars."
tests["<table><tr><td>First</td><td>Second</td></table>"] = "First Second"
tests["<h1>Heading</h1><p>Paragraph</p>"] = "Heading Paragraph"
tests[`<h1>Heading</h1>
<p>Paragraph</p>`] = "Heading Paragraph"
tests[`<h1>Heading</h1><p> <a href="https://github.com">linked text</a></p>`] = "Heading https://github.com linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading https://github.com linked text."
for str, expected := range tests {
res := Strip(str, true)
if res != expected {
t.Log("error:", res, "!=", expected)
t.Fail()
}
}
}
func BenchmarkPlain(b *testing.B) {
for i := 0; i < b.N; i++ {
Strip(htmlTestData, false)
}
}
func BenchmarkLinks(b *testing.B) {
for i := 0; i < b.N; i++ {
Strip(htmlTestData, true)
}
}
var htmlTestData = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="font-family: sans-serif; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; box-sizing: border-box;" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<title>[axllent/mailpit] Run failed: .github/workflows/tests.yml - feature/swagger (284335a)</title>
</head>
<body style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;; font-size: 14px; line-height: 1.5; color: #24292e; background-color: #fff; margin: 0;" bgcolor="#fff">
<table align="center" class="container-sm width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 544px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="center p-3" align="center" valign="top" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 16px;">
<center style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full container-md" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; max-width: 768px; margin-right: auto; margin-left: auto; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="left" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="text-left" style="box-sizing: border-box; text-align: left !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;" align="left">
<img src="https://github.githubassets.com/images/email/global/octocat-logo.png" alt="GitHub" width="32" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;" />
<h2 class="lh-condensed mt-2 text-normal" style="box-sizing: border-box; margin-top: 8px !important; margin-bottom: 0; font-size: 24px; font-weight: 400 !important; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
[axllent/mailpit] .github/workflows/tests.yml workflow run
</h2>
</td>
</tr>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
<table width="100%" class="width-full" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="border rounded-2 d-block" style="box-sizing: border-box; border-radius: 6px !important; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0; border: 1px solid #e1e4e8;">
<table align="center" class="width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table align="center" class="border-bottom width-full text-center" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; border-bottom-width: 1px !important; border-bottom-color: #e1e4e8 !important; border-bottom-style: solid !important; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td class="d-block px-3 pt-3 p-sm-4" style="box-sizing: border-box; display: block !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 24px;">
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<img src="https://github.githubassets.com/images/email/icons/actions.png" width="56" height="56" alt="" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; border-style: none;" />
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="12" style="font-size: 12px; line-height: 12px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<h3 class="lh-condensed" style="box-sizing: border-box; margin-top: 0; margin-bottom: 0; font-size: 20px; font-weight: 600; line-height: 1.25 !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">.github/workflows/tests.yml: No jobs were run</h3>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table border="0" cellspacing="0" cellpadding="0" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<!--[if mso]> <table><tr><td align="center" bgcolor="#28a745"> <![endif]-->
<a href="https://github.com/axllent/mailpit/actions/runs/6522820865" target="_blank" rel="noopener noreferrer" class="btn btn-large btn-primary" style="background-color: #1f883d !important; box-sizing: border-box; color: #fff; text-decoration: none; position: relative; display: inline-block; font-size: inherit; font-weight: 500; line-height: 1.5; white-space: nowrap; vertical-align: middle; cursor: pointer; -webkit-user-select: none; user-select: none; border-radius: .5em; -webkit-appearance: none; appearance: none; box-shadow: 0 1px 0 rgba(27,31,35,.1),inset 0 1px 0 rgba(255,255,255,.03); transition: background-color .2s cubic-bezier(0.3, 0, 0.5, 1); font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: .75em 1.5em; border: 1px solid #1f883d;">View workflow run</a>
<!--[if mso]> </td></tr></table> <![endif]-->
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="32" style="font-size: 32px; line-height: 32px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<p class="f5 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 14px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;"> </p><p style="font-size: small; -webkit-text-size-adjust: none; color: #666; box-sizing: border-box; margin-top: 0; margin-bottom: 10px; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">&#8212;<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;" />You are receiving this because you are subscribed to this thread.<br style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;" /><a href="https://github.com/settings/notifications" style="background-color: transparent; box-sizing: border-box; color: #0366d6; text-decoration: none; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">Manage your GitHub Actions notifications</a></p>
</td>
</tr>
</table>
<table border="0" cellspacing="0" cellpadding="0" align="center" class="width-full text-center" width="100%" style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; width: 100% !important; text-align: center !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td align="center" style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">
<table style="box-sizing: border-box; border-spacing: 0; border-collapse: collapse; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tbody style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<tr style="box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">
<td height="16" style="font-size: 16px; line-height: 16px; box-sizing: border-box; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important; padding: 0;">&#160;</td>
</tr>
</tbody>
</table>
<p class="f6 text-gray-light" style="box-sizing: border-box; margin-top: 0; margin-bottom: 10px; color: #6a737d !important; font-size: 12px !important; font-family: -apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot; !important;">GitHub, Inc. &#12539;88 Colin P Kelly Jr Street &#12539;San Francisco, CA 94107</p>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
<!-- prevent Gmail on iOS font size manipulation -->
<div style="display: none; white-space: nowrap; box-sizing: border-box; font: 15px/0 apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;"> &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; &#160; </div>
</body>
</html>`

View File

@@ -1,5 +0,0 @@
# HTML check
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
// Package htmlcheck is used for parsing HTML and returning
// HTML compatibility errors and warnings
package htmlcheck
import (
"embed"
"encoding/json"
"regexp"
)
//go:embed caniemail-data.json
var embeddedFS embed.FS
var (
cie = CanIEmail{}
noteMatch = regexp.MustCompile(` #(\d)+$`)
// LimitFamilies will limit results to families if set
LimitFamilies = []string{}
// LimitPlatforms will limit results to platforms if set
LimitPlatforms = []string{}
// LimitClients will limit results to clients if set
LimitClients = []string{}
)
// CanIEmail struct for JSON data
type CanIEmail struct {
APIVersion string `json:"api_version"`
LastUpdateDate string `json:"last_update_date"`
// NiceNames map[string]string `json:"last_update_date"`
NiceNames struct {
Family map[string]string `json:"family"`
Platform map[string]string `json:"platform"`
Support map[string]string `json:"support"`
Category map[string]string `json:"category"`
} `json:"nicenames"`
Data []JSONResult `json:"data"`
}
// JSONResult struct for CanIEmail Data
type JSONResult struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Category string `json:"category"`
Tags []string `json:"tags"`
Keywords string `json:"keywords"`
LastTestDate string `json:"last_test_date"`
TestURL string `json:"test_url"`
TestResultsURL string `json:"test_results_url"`
Stats map[string]any `json:"stats"`
Notes string `json:"notes"`
NotesByNumber map[string]string `json:"notes_by_num"`
}
// Load the JSON data
func loadJSONData() error {
if cie.APIVersion != "" {
return nil
}
b, err := embeddedFS.ReadFile("caniemail-data.json")
if err != nil {
return err
}
cie = CanIEmail{}
return json.Unmarshal(b, &cie)
}

View File

@@ -1,215 +0,0 @@
package htmlcheck
import "regexp"
// HTML tests
var htmlTests = map[string]string{
// body check is manually done because it always exists in *goquery.Document
"html-body": "body",
// HTML tests
"html-object": "object, embed, image, pdf",
"html-link": "link",
"html-hr": "hr",
"html-dialog": "dialog",
"html-srcset": "[srcset]",
"html-picture": "picture",
"html-svg": "svg",
"html-progress": "progress",
"html-required": "[required]",
"html-meter": "meter",
"html-audio": "audio",
"html-form": "form",
"html-input-submit": "submit",
"html-button-reset": "button[type=\"reset\"]",
"html-button-submit": "submit, button[type=\"submit\"]",
"html-base": "base",
"html-input-checkbox": "checkbox",
"html-input-hidden": "[type=\"hidden\"]",
"html-input-radio": "radio",
"html-input-text": "input[type=\"text\"]",
"html-video": "video",
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
"html-select": "select",
"html-textarea": "textarea",
"html-anchor-links": "a[href^=\"#\"]",
"html-style": "style",
"html-image-maps": "map, img[usemap]",
}
// Image tests using regex to match against img[src]
var imageRegexpTests = map[string]*regexp.Regexp{
"image-apng": regexp.MustCompile(`(?i)\.apng$`),
"image-avif": regexp.MustCompile(`(?i)\.avif$`),
"image-base64": regexp.MustCompile(`^(?i)data:image\/`),
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`),
"image-gif": regexp.MustCompile(`(?i)\.gif$`),
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`),
"image-heif": regexp.MustCompile(`(?i)\.heif$`),
"image-ico": regexp.MustCompile(`(?i)\.ico$`),
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`),
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`),
"image-svg": regexp.MustCompile(`(?i)\.svg$`),
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`),
"image-webp": regexp.MustCompile(`(?i)\.webp$`),
}
// inline attribute <match>=""
var styleInlineAttributes = map[string]string{
"css-background-color": "[bgcolor]",
"css-background": "[background]",
"css-border": "[border]",
"css-height": "[height]",
"css-padding": "[padding]",
"css-width": "[width]",
}
// inline style="<match>"
var cssInlineRegexTests = map[string]*regexp.Regexp{
"css-accent-color": regexp.MustCompile(`(?i)(^|\s|;)accent-color(\s+)?:`),
"css-align-items": regexp.MustCompile(`(?i)(^|\s|;)align-items(\s+)?:`),
"css-aspect-ratio": regexp.MustCompile(`(?i)(^|\s|;)aspect-ratio(\s+)?:`),
"css-background-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)background-blend-mode(\s+)?:`),
"css-background-clip": regexp.MustCompile(`(?i)(^|\s|;)background-clip(\s+)?:`),
"css-background-color": regexp.MustCompile(`(?i)(^|\s|;)background-color(\s+)?:`),
"css-background-image": regexp.MustCompile(`(?i)(^|\s|;)background-image(\s+)?:`),
"css-background-origin": regexp.MustCompile(`(?i)(^|\s|;)background-origin(\s+)?:`),
"css-background-position": regexp.MustCompile(`(?i)(^|\s|;)background-position(\s+)?:`),
"css-background-repeat": regexp.MustCompile(`(?i)(^|\s|;)background-repeat(\s+)?:`),
"css-background-size": regexp.MustCompile(`(?i)(^|\s|;)background-size(\s+)?:`),
"css-background": regexp.MustCompile(`(?i)(^|\s|;)background(\s+)?:`),
"css-block-inline-size": regexp.MustCompile(`(?i)(^|\s|;)block-inline-size(\s+)?:`),
"css-border-image": regexp.MustCompile(`(?i)(^|\s|;)border-image(\s+)?:`),
"css-border-inline-block-individual": regexp.MustCompile(`(?i)(^|\s|;)border-inline(\s+)?:`),
"css-border-radius": regexp.MustCompile(`(?i)(^|\s|;)border-radius(\s+)?:`),
"css-border": regexp.MustCompile(`(?i)(^|\s|;)border(\s+)?:`),
"css-box-shadow": regexp.MustCompile(`(?i)(^|\s|;)box-shadow(\s+)?:`),
"css-box-sizing": regexp.MustCompile(`(?i)(^|\s|;)box-sizing(\s+)?:`),
"css-caption-side": regexp.MustCompile(`(?i)(^|\s|;)caption-side(\s+)?:`),
"css-clip-path": regexp.MustCompile(`(?i)(^|\s|;)clip-path(\s+)?:`),
"css-column-count": regexp.MustCompile(`(?i)(^|\s|;)column-count(\s+)?:`),
"css-column-layout-properties": regexp.MustCompile(`(?i)(^|\s|;)column-layout-properties(\s+)?:`),
"css-conic-gradient": regexp.MustCompile(`(?i)(^|\s|;)conic-gradient(\s+)?:`),
"css-direction": regexp.MustCompile(`(?i)(^|\s|;)direction(\s+)?:`),
"css-display-flex": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:(\s+)?flex($|\s|;)`),
"css-display-grid": regexp.MustCompile(`(?i)(^|\s|;)display:grid`),
"css-display-none": regexp.MustCompile(`(?i)(^|\s|;)display:none`),
"css-display": regexp.MustCompile(`(?i)(^|\s|;)display(\s+)?:`),
"css-filter": regexp.MustCompile(`(?i)(^|\s|;)filter(\s+)?:`),
"css-flex-direction": regexp.MustCompile(`(?i)(^|\s|;)flex-direction(\s+)?:`),
"css-flex-wrap": regexp.MustCompile(`(?i)(^|\s|;)flex-wrap(\s+)?:`),
"css-float": regexp.MustCompile(`(?i)(^|\s|;)float(\s+)?:`),
"css-font-kerning": regexp.MustCompile(`(?i)(^|\s|;)font-kerning(\s+)?:`),
"css-font-weight": regexp.MustCompile(`(?i)(^|\s|;)font-weight(\s+)?:`),
"css-font": regexp.MustCompile(`(?i)(^|\s|;)font(\s+)?:`),
"css-gap": regexp.MustCompile(`(?i)(^|\s|;)gap(\s+)?:`),
"css-grid-template": regexp.MustCompile(`(?i)(^|\s|;)grid-template(\s+)?:`),
"css-height": regexp.MustCompile(`(?i)(^|\s|;)height(\s+)?:`),
"css-hyphens": regexp.MustCompile(`(?i)(^|\s|;)hyphens(\s+)?:`),
"css-important": regexp.MustCompile(`(?i)!important($|\s|;)`),
"css-inline-size": regexp.MustCompile(`(?i)(^|\s|;)inline-size(\s+)?:`),
"css-intrinsic-size": regexp.MustCompile(`(?i)(^|\s|;)intrinsic-size(\s+)?:`),
"css-justify-content": regexp.MustCompile(`(?i)(^|\s|;)justify-content(\s+)?:`),
"css-letter-spacing": regexp.MustCompile(`(?i)(^|\s|;)letter-spacing(\s+)?:`),
"css-line-height": regexp.MustCompile(`(?i)(^|\s|;)line-height(\s+)?:`),
"css-list-style-image": regexp.MustCompile(`(?i)(^|\s|;)list-style-image(\s+)?:`),
"css-list-style-position": regexp.MustCompile(`(?i)(^|\s|;)list-style-position(\s+)?:`),
"css-list-style": regexp.MustCompile(`(?i)(^|\s|;)list-style(\s+)?:`),
"css-margin-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-block-(start|end)(\s+)?:`),
"css-margin-inline-block": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-block(\s+)?:`),
"css-margin-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)margin-inline-(start|end)(\s+)?:`),
"css-margin-inline": regexp.MustCompile(`(?i)(^|\s|;)margin-inline(\s+)?:`),
"css-margin": regexp.MustCompile(`(?i)(^|\s|;)margin(\s+)?:`),
"css-max-block-size": regexp.MustCompile(`(?i)(^|\s|;)max-block-size(\s+)?:`),
"css-max-height": regexp.MustCompile(`(?i)(^|\s|;)max-height(\s+)?:`),
"css-max-width": regexp.MustCompile(`(?i)(^|\s|;)max-width(\s+)?:`),
"css-min-height": regexp.MustCompile(`(?i)(^|\s|;)min-height(\s+)?:`),
"css-min-inline-size": regexp.MustCompile(`(?i)(^|\s|;)min-inline-size(\s+)?:`),
"css-min-width": regexp.MustCompile(`(?i)(^|\s|;)min-width(\s+)?:`),
"css-mix-blend-mode": regexp.MustCompile(`(?i)(^|\s|;)mix-blend-mode(\s+)?:`),
"css-modern-color": regexp.MustCompile(`(?i)(^|\s|;)modern-color(\s+)?:`),
"css-object-fit": regexp.MustCompile(`(?i)(^|\s|;)object-fit(\s+)?:`),
"css-object-position": regexp.MustCompile(`(?i)(^|\s|;)object-position(\s+)?:`),
"css-opacity": regexp.MustCompile(`(?i)(^|\s|;)opacity(\s+)?:`),
"css-outline-offset": regexp.MustCompile(`(?i)(^|\s|;)outline-offset(\s+)?:`),
"css-outline": regexp.MustCompile(`(?i)(^|\s|;)outline(\s+)?:`),
"css-overflow-wrap": regexp.MustCompile(`(?i)(^|\s|;)overflow-wrap(\s+)?:`),
"css-overflow": regexp.MustCompile(`(?i)(^|\s|;)overflow(\s+)?:`),
"css-padding-block-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-block-(start|end)(\s+)?:`),
"css-padding-inline-block": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-block(\s+)?:`),
"css-padding-inline-start-end": regexp.MustCompile(`(?i)(^|\s|;)padding-inline-(start|end)(\s+)?:`),
"css-padding": regexp.MustCompile(`(?i)(^|\s|;)padding(\s+)?:`),
"css-position": regexp.MustCompile(`(?i)(^|\s|;)position(\s+)?:`),
"css-radial-gradient": regexp.MustCompile(`(?i)(^|\s|;)radial-gradient(\s+)?:`),
"css-rgb": regexp.MustCompile(`(?i)(\s|:)rgb\(`),
"css-rgba": regexp.MustCompile(`(?i)(\s|:)rgba\(`),
"css-scroll-snap": regexp.MustCompile(`(?i)(^|\s|;)roll-snap(\s+)?:`),
"css-tab-size": regexp.MustCompile(`(?i)(^|\s|;)tab-size(\s+)?:`),
"css-table-layout": regexp.MustCompile(`(?i)(^|\s|;)table-layout(\s+)?:`),
"css-text-align-last": regexp.MustCompile(`(?i)(^|\s|;)text-align-last(\s+)?:`),
"css-text-align": regexp.MustCompile(`(?i)(^|\s|;)text-align(\s+)?:`),
"css-text-decoration-color": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-color(\s+)?:`),
"css-text-decoration-thickness": regexp.MustCompile(`(?i)(^|\s|;)text-decoration-thickness(\s+)?:`),
"css-text-decoration": regexp.MustCompile(`(?i)(^|\s|;)text-decoration(\s+)?:`),
"css-text-emphasis-position": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis-position(\s+)?:`),
"css-text-emphasis": regexp.MustCompile(`(?i)(^|\s|;)text-emphasis(\s+)?:`),
"css-text-indent": regexp.MustCompile(`(?i)(^|\s|;)text-indent(\s+)?:`),
"css-text-overflow": regexp.MustCompile(`(?i)(^|\s|;)text-overflow(\s+)?:`),
"css-text-shadow": regexp.MustCompile(`(?i)(^|\s|;)text-shadow(\s+)?:`),
"css-text-transform": regexp.MustCompile(`(?i)(^|\s|;)text-transform(\s+)?:`),
"css-text-underline-offset": regexp.MustCompile(`(?i)(^|\s|;)text-underline-offset(\s+)?:`),
"css-transform": regexp.MustCompile(`(?i)(^|\s|;)transform(\s+)?:`),
"css-unit-calc": regexp.MustCompile(`(?i)(\s|:)calc\(`),
"css-variables": regexp.MustCompile(`(?i)(^|\s|;)variables(\s+)?:`),
"css-visibility": regexp.MustCompile(`(?i)(^|\s|;)visibility(\s+)?:`),
"css-white-space": regexp.MustCompile(`(?i)(^|\s|;)white-space(\s+)?:`),
"css-width": regexp.MustCompile(`(?i)(^|\s|;)width(\s+)?:`),
"css-word-break": regexp.MustCompile(`(?i)(^|\s|;)word-break(\s+)?:`),
"css-writing-mode": regexp.MustCompile(`(?i)(^|\s|;)writing-mode(\s+)?:`),
"css-z-index": regexp.MustCompile(`(?i)(^|\s|;)z-index(\s+)?:`),
}
// some CSS tests using regex for things that can't be merged inline
var cssRegexpTests = map[string]*regexp.Regexp{
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`),
"css-at-import": regexp.MustCompile(`(?mi)@import\s`),
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`),
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`),
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`),
"css-pseudo-class-active": regexp.MustCompile(`:active`),
"css-pseudo-class-checked": regexp.MustCompile(`:checked`),
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`),
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`),
"css-pseudo-class-focus": regexp.MustCompile(`:focus`),
"css-pseudo-class-has": regexp.MustCompile(`:has`),
"css-pseudo-class-hover": regexp.MustCompile(`:hover`),
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`),
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`),
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`),
"css-pseudo-class-link": regexp.MustCompile(`:link`),
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`),
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`),
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`),
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`),
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`),
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`),
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`),
"css-pseudo-class-target": regexp.MustCompile(`:target`),
"css-pseudo-class-visited": regexp.MustCompile(`:visited`),
"css-pseudo-element-after": regexp.MustCompile(`:after`),
"css-pseudo-element-before": regexp.MustCompile(`:before`),
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`),
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`),
"css-pseudo-element-marker": regexp.MustCompile(`::marker`),
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`),
}
// some CSS tests using regex for units
var cssRegexpUnitTests = map[string]*regexp.Regexp{
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`),
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`),
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`),
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`),
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`),
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`),
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`),
}

View File

@@ -1,310 +0,0 @@
package htmlcheck
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/vanng822/go-premailer/premailer"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Go cannot calculate any rendered CSS attributes, so we merge all styles
// into the HTML and detect elements with styles containing the keywords.
func runCSSTests(html string) ([]Warning, int, error) {
results := []Warning{}
totalTests := 0
inlined, err := inlineRemoteCSS(html)
if err != nil {
inlined = html
}
// merge all CSS inline
merged, err := mergeInlineCSS(inlined)
if err != nil {
merged = inlined
}
reader := strings.NewReader(merged)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return results, totalTests, err
}
inlineStyleResults := testInlineStyles(doc)
totalTests = totalTests + len(cssInlineRegexTests) + len(styleInlineAttributes)
for key, count := range inlineStyleResults {
result, err := cie.getTest(key)
if err == nil {
result.Score.Found = count
results = append(results, result)
}
}
// get a list of all generated styles from all nodes
allNodeStyles := []string{}
for _, n := range doc.Find("*[style]").Nodes {
style, err := tools.GetHTMLAttributeVal(n, "style")
if err == nil {
allNodeStyles = append(allNodeStyles, style)
}
}
for key, re := range cssRegexpUnitTests {
totalTests++
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
found := 0
// loop through all styles to count total
for _, styles := range allNodeStyles {
found = found + len(re.FindAllString(styles, -1))
}
if found > 0 {
result.Score.Found = found
results = append(results, result)
}
}
// get all inline CSS block data
reader = strings.NewReader(inlined)
// Load the HTML document
doc, _ = goquery.NewDocumentFromReader(reader)
cssCode := ""
for _, n := range doc.Find("style").Nodes {
for c := n.FirstChild; c != nil; c = c.NextSibling {
cssCode = cssCode + c.Data
}
}
for key, re := range cssRegexpTests {
totalTests++
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
found := len(re.FindAllString(cssCode, -1))
if found > 0 {
result.Score.Found = found
results = append(results, result)
}
}
return results, totalTests, nil
}
// MergeInlineCSS merges header CSS into element attributes
func mergeInlineCSS(html string) (string, error) {
options := premailer.NewOptions()
// options.RemoveClasses = true
// options.CssToAttributes = false
options.KeepBangImportant = true
pre, err := premailer.NewPremailerFromString(html, options)
if err != nil {
return "", err
}
return pre.Transform()
}
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
func inlineRemoteCSS(h string) (string, error) {
reader := strings.NewReader(h)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return h, err
}
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range remoteCSS {
attributes := link.Attr
for _, a := range attributes {
if a.Key == "href" {
if config.BlockRemoteCSSAndFonts {
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
return h, nil
}
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] %s", err.Error())
continue
}
// create new <style> block and insert downloaded CSS
styleBlock := &html.Node{
Type: html.ElementNode,
Data: "style",
DataAtom: atom.Style,
}
styleBlock.AppendChild(&html.Node{
Type: html.TextNode,
Data: string(resp),
})
link.Parent.AppendChild(styleBlock)
}
}
}
newDoc, err := doc.Html()
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", err.Error())
return h, err
}
return newDoc, nil
}
// 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
}
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)
return nil, err
}
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
}
return body, nil
}
// 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.Hostname() != "" && u.User.String() == ""
}
// Test the HTML for inline CSS styles and styling attributes
func testInlineStyles(doc *goquery.Document) map[string]int {
matches := make(map[string]int)
// find all elements containing a style attribute
styles := doc.Find("[style]").Nodes
for _, s := range styles {
style, err := tools.GetHTMLAttributeVal(s, "style")
if err != nil {
continue
}
for id, test := range cssInlineRegexTests {
if test.MatchString(style) {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
}
// find all elements containing styleInlineAttributes
for id, test := range styleInlineAttributes {
a := doc.Find(test).Nodes
if len(a) > 0 {
if _, ok := matches[id]; !ok {
matches[id] = 0
}
matches[id]++
}
}
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
}

View File

@@ -1,102 +0,0 @@
package htmlcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/tools"
)
// HTML tests
func runHTMLTests(html string) ([]Warning, int, error) {
results := []Warning{}
totalTests := 0
reader := strings.NewReader(html)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return results, totalTests, err
}
// Almost all <script> is bad
scripts := len(doc.Find("script:not([type=\"application/ld+json\"]):not([type=\"application/json\"])").Nodes)
if scripts > 0 {
var result = Warning{}
result.Title = "<script> element"
result.Slug = "html-script"
result.Category = "html"
result.Description = "JavaScript is not supported in any email client."
result.Tags = []string{}
result.Results = []Result{}
result.NotesByNumber = map[string]string{}
result.Score.Found = scripts
result.Score.Supported = 0
result.Score.Partial = 0
result.Score.Unsupported = 100
results = append(results, result)
totalTests++
}
for key, test := range htmlTests {
totalTests++
if test == "body" {
re := regexp.MustCompile(`(?im)</body>`)
if re.MatchString(html) {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = 1
results = append(results, result)
}
} else if len(doc.Find(test).Nodes) > 0 {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
totalTests++
result.Score.Found = len(doc.Find(test).Nodes)
results = append(results, result)
}
}
// find all images
images := doc.Find("img[src]").Nodes
imageResults := make(map[string]int)
totalTests = totalTests + len(imageRegexpTests)
for _, image := range images {
src, err := tools.GetHTMLAttributeVal(image, "src")
if err != nil {
continue
}
for key, test := range imageRegexpTests {
if test.MatchString(src) {
matches, exists := imageResults[key]
if exists {
imageResults[key] = matches + 1
} else {
imageResults[key] = 1
}
}
}
}
for key, found := range imageResults {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = found
results = append(results, result)
}
return results, totalTests, nil
}

View File

@@ -1,81 +0,0 @@
package htmlcheck
import (
"fmt"
"sort"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
)
func TestInlineStyleDetection(t *testing.T) {
/// tests should contain the HTML test, and expected test results in alphabetical order
tests := map[string]string{}
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color: green; transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="color:green; transform :rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="TRANSFORM:rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="transform: rotate(20deg)">Heading</h1>`] = "css-transform"
tests[`<h1 style="ignore-transform: something">Heading</h1>`] = "" // no match
tests[`<h1 style="text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="text-transform: uppercase; text-transform: uppercase">Heading</h1>`] = "css-text-transform"
tests[`<h1 style="test-transform: uppercase">Heading</h1>`] = "" // no match
tests[`<h1 style="padding-inline-start: 5rem">Heading</h1>`] = "css-padding-inline-start-end"
tests[`<h1 style="margin-inline-end: 5rem">Heading</h1>`] = "css-margin-inline-start-end"
tests[`<h1 style="margin-inline-middle: 5rem">Heading</h1>`] = "" // no match
tests[`<h1 style="color:green!important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green !important">Heading</h1>`] = "css-important"
tests[`<h1 style="color: green!important;">Heading</h1>`] = "css-important"
tests[`<h1 style="color:green!important-stuff;">Heading</h1>`] = "" // no match
tests[`<h1 style="background-image:url('img.jpg')">Heading</h1>`] = "css-background-image"
tests[`<h1 style="background-image:url('img.jpg'); color: green">Heading</h1>`] = "css-background-image"
tests[`<h1 style=" color:green; background-image:url('img.jpg');">Heading</h1>`] = "css-background-image"
tests[`<h1 style="display : flex ;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="DISPLAY:FLEX;">Heading</h1>`] = "css-display,css-display-flex"
tests[`<h1 style="display: flexing;">Heading</h1>`] = "css-display" // should not match css-display-flex rule
tests[`<h1 style="line-height: 1rem;opacity: 0.5; width: calc(10px + 100px)">Heading</h1>`] = "css-line-height,css-opacity,css-unit-calc,css-width"
tests[`<h1 style="color: rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgb(255,255,255);">Heading</h1>`] = "css-rgb"
tests[`<h1 style="color:rgba(255,255,255, 0);">Heading</h1>`] = "css-rgba"
tests[`<h1 style="border: solid rgb(255,255,255) 1px; color:rgba(255,255,255, 0);">Heading</h1>`] = "css-border,css-rgb,css-rgba"
tests[`<h1 border="2">Heading</h1>`] = "css-border"
tests[`<h1 border="2" background="green">Heading</h1>`] = "css-background,css-border"
tests[`<h1 BORDER="2" BACKGROUND="GREEN">Heading</h1>`] = "css-background,css-border"
tests[`<h1 border-something="2" background-something="green">Heading</h1>`] = "" // no match
tests[`<h1 border="2" style="border: solid green 1px!important">Heading</h1>`] = "css-border,css-important"
for html, expected := range tests {
reader := strings.NewReader(html)
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
t.Log("error ", err)
t.Fail()
}
results := testInlineStyles(doc)
matches := []string{}
uniqMap := make(map[string]bool)
for key := range results {
if _, exists := uniqMap[key]; !exists {
matches = append(matches, key)
}
}
// ensure results are sorted to ensure consistent results
sort.Strings(matches)
assertEqual(t, expected, strings.Join(matches, ","), fmt.Sprintf("inline style detection \"%s\"", html))
}
}
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

View File

@@ -1,202 +0,0 @@
package htmlcheck
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/tools"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
// RunTests will run all tests on an HTML string
func RunTests(html string) (Response, error) {
s := Response{}
s.Warnings = []Warning{}
if platforms, err := Platforms(); err == nil {
s.Platforms = platforms
}
s.Total = Total{}
// crude way to determine whether the HTML contains a <body> structure
// or whether it's just plain HTML content
re := regexp.MustCompile(`(?im)</body>`)
nodeMatch := "body *, script"
if re.MatchString(html) {
nodeMatch = "*:not(html):not(head):not(meta), script"
}
reader := strings.NewReader(html)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return s, err
}
// calculate the number of nodes in HTML
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
if err := loadJSONData(); err != nil {
return s, err
}
// HTML tests
htmlResults, totalTests, err := runHTMLTests(html)
if err != nil {
return s, err
}
s.Total.Tests = s.Total.Tests + totalTests
// add html test totals
s.Warnings = append(s.Warnings, htmlResults...)
// CSS tests
cssResults, totalTests, err := runCSSTests(html)
if err != nil {
return s, err
}
s.Total.Tests = s.Total.Tests + totalTests
// add css test totals
s.Warnings = append(s.Warnings, cssResults...)
// calculate total score
var partial, unsupported float32
partial = 0
unsupported = 0
for _, w := range s.Warnings {
if w.Score.Found == 0 {
continue
}
// supported is calculated by subtracting partial and unsupported from 100%
if w.Score.Partial > 0 {
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
if weighted > partial {
partial = weighted
}
}
if w.Score.Unsupported > 0 {
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
if weighted > unsupported {
unsupported = weighted
}
}
}
s.Total.Supported = 100 - partial - unsupported
s.Total.Partial = partial
s.Total.Unsupported = unsupported
// sort slice to get lowest scores first
sort.Slice(s.Warnings, func(i, j int) bool {
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
})
return s, nil
}
// Test returns a test
func (c CanIEmail) getTest(k string) (Warning, error) {
warning := Warning{}
exists := false
found := JSONResult{}
for _, r := range cie.Data {
if r.Slug == k {
found = r
exists = true
break
}
}
if !exists {
return warning, fmt.Errorf("%s does not exist", k)
}
warning.Slug = found.Slug
warning.Title = found.Title
warning.Description = mdToHTML(found.Description)
warning.Category = found.Category
warning.URL = found.URL
warning.Tags = found.Tags
// warning.Keywords = found.Keywords
// warning.Notes = found.Notes
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
for nr, note := range found.NotesByNumber {
warning.NotesByNumber[nr] = mdToHTML(note)
}
warning.Results = []Result{}
var y, n, p float32
for family, stats := range found.Stats {
if len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {
continue
}
for platform, clients := range stats.(map[string]any) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]any) {
s := Result{}
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
s.Family = family
s.Platform = platform
s.Version = version
switch support {
case "y":
y++
s.Support = "yes"
case "n":
n++
s.Support = "no"
default:
p++
s.Support = "partial"
noteIDs := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
for _, id := range noteIDs {
s.NoteNumber = id
}
}
warning.Results = append(warning.Results, s)
}
}
}
total := y + n + p
warning.Score.Supported = y / total * 100
warning.Score.Unsupported = n / total * 100
warning.Score.Partial = p / total * 100
return warning, nil
}
// Convert markdown to HTML, stripping <p> & </p>
func mdToHTML(str string) string {
md := []byte(str)
// create markdown parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
// extensions := parser.NoExtensions
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
}

View File

@@ -1,40 +0,0 @@
package htmlcheck
import (
"slices"
"github.com/axllent/mailpit/internal/tools"
)
// Platforms returns all platforms with their respective email clients
func Platforms() (map[string][]string, error) {
// [platform]clients
data := make(map[string][]string)
if err := loadJSONData(); err != nil {
return data, err
}
for _, t := range cie.Data {
for family, stats := range t.Stats {
niceFamily := cie.NiceNames.Family[family]
for platform := range stats.(map[string]any) {
c, found := data[platform]
if !found {
data[platform] = []string{}
}
if !tools.InArray(niceFamily, c) {
c = append(c, niceFamily)
data[platform] = c
}
}
}
}
for group, clients := range data {
slices.Sort(clients)
data[group] = clients
}
return data, nil
}

View File

@@ -1,87 +0,0 @@
package htmlcheck
// Response represents the HTML check response struct
//
// swagger:model HTMLCheckResponse
type Response struct {
// List of warnings from tests
Warnings []Warning `json:"Warnings"`
// All platforms tested, mainly for the web UI
Platforms map[string][]string `json:"Platforms"`
// Total overall score
Total Total `json:"Total"`
}
// Warning represents a failed test
//
// swagger:model HTMLCheckWarning
type Warning struct {
// Slug identifier
Slug string `json:"Slug"`
// Friendly title
Title string `json:"Title"`
// Description
Description string `json:"Description"`
// URL to caniemail.com
URL string `json:"URL"`
// Category [css, html]
Category string `json:"Category"`
// Tags
Tags []string `json:"Tags"`
// Keywords
Keywords string `json:"Keywords"`
// Test results
Results []Result `json:"Results"`
// Notes based on results
NotesByNumber map[string]string `json:"NotesByNumber"`
// Test score calculated from results
Score Score `json:"Score"`
}
// Result struct
//
// swagger:model HTMLCheckResult
type Result struct {
// Friendly name of result, combining family, platform & version
Name string `json:"Name"`
// Platform eg: ios, android, windows
Platform string `json:"Platform"`
// Family eg: Outlook, Mozilla Thunderbird
Family string `json:"Family"`
// Family version eg: 4.7.1, 2019-10, 10.3
Version string `json:"Version"`
// Support [yes, no, partial]
Support string `json:"Support"`
// Note number for partially supported if applicable
NoteNumber string `json:"NoteNumber"` // where applicable
}
// Score struct
//
// swagger:model HTMLCheckScore
type Score struct {
// Number of matches in the document
Found int `json:"Found"`
// Total percentage supported
Supported float32 `json:"Supported"`
// Total percentage partially supported
Partial float32 `json:"Partial"`
// Total percentage unsupported
Unsupported float32 `json:"Unsupported"`
}
// Total weighted result for all scores
//
// swagger:model HTMLCheckTotal
type Total struct {
// Total number of tests done
Tests int `json:"Tests"`
// Total number of HTML nodes detected in message
Nodes int `json:"Nodes"`
// Overall percentage supported
Supported float32 `json:"Supported"`
// Overall percentage partially supported
Partial float32 `json:"Partial"` // total percentage
// Overall percentage unsupported
Unsupported float32 `json:"Unsupported"` // total percentage
}

View File

@@ -1,86 +0,0 @@
package linkcheck
import (
"reflect"
"testing"
"github.com/axllent/mailpit/internal/storage"
)
var (
testHTML = `
<html>
<head>
<link rel=stylesheet href="http://remote-host/style.css"></link>
<script async src="https://www.googletagmanager.com/gtag/js?id=ignored"></script>
</head>
<body>
<div>
<p><a href="http://example.com">HTTP link</a></p>
<p><a href="https://example.com">HTTPS link</a></p>
<p><a href="HTTPS://EXAMPLE.COM">HTTPS link</a></p>
<p><a href="http://localhost">Localhost link</a> (ignored)</p>
<p><a href="https://localhost">Localhost link</a> (ignored)</p>
<p><a href='https://127.0.0.1'>Single quotes link</a> (ignored)</p>
<p><img src=https://example.com/image.jpg></p>
<p href="http://invalid-link.com">This should be ignored</p>
<p><a href="http://link with spaces">Link with spaces</a></p>
<p><a href="http://example.com/?blaah=yes&amp;test=true">URL-encoded characters</a></p>
</div>
</body>
</html>`
expectedHTMLLinks = []string{
"http://example.com",
"https://example.com",
"HTTPS://EXAMPLE.COM",
"http://localhost",
"https://localhost",
"https://127.0.0.1",
"http://link with spaces",
"http://example.com/?blaah=yes&test=true",
"http://remote-host/style.css", // css
"https://example.com/image.jpg", // images
}
testTextLinks = `This is a line with http://example.com https://example.com
HTTPS://EXAMPLE.COM
[http://localhost]
www.google.com < ignored
|||http://example.com/?some=query-string|||
// 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",
"https://example.com/ link with spaces",
}
)
func TestLinkDetection(t *testing.T) {
t.Log("Testing HTML link detection")
m := storage.Message{}
m.Text = testTextLinks
m.HTML = testHTML
textLinks := extractTextLinks(&m)
if !reflect.DeepEqual(textLinks, expectedTextLinks) {
t.Fatalf("Failed to detect text links correctly")
}
htmlLinks := extractHTMLLinks(&m)
if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) {
t.Fatalf("Failed to detect HTML links correctly")
}
}

View File

@@ -1,109 +0,0 @@
// Package linkcheck handles message links checking
package linkcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
)
var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`)
// RunTests will run all tests on an HTML string
func RunTests(msg *storage.Message, followRedirects bool) (Response, error) {
s := Response{}
allLinks := extractHTMLLinks(msg)
allLinks = strUnique(append(allLinks, extractTextLinks(msg)...))
s.Links = getHTTPStatuses(allLinks, followRedirects)
for _, l := range s.Links {
if l.StatusCode >= 400 || l.StatusCode == 0 {
s.Errors++
}
}
return s, nil
}
func extractTextLinks(msg *storage.Message) []string {
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{}
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
}
func extractHTMLLinks(msg *storage.Message) []string {
links := []string{}
reader := strings.NewReader(msg.HTML)
// Load the HTML document
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return links
}
aLinks := doc.Find("a[href]").Nodes
for _, link := range aLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes
for _, link := range cssLinks {
l, err := tools.GetHTMLAttributeVal(link, "href")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
imgLinks := doc.Find("img[src]").Nodes
for _, link := range imgLinks {
l, err := tools.GetHTMLAttributeVal(link, "src")
if err == nil && linkRe.MatchString(l) {
links = append(links, l)
}
}
return links
}
// strUnique return a slice of unique strings from a slice
func strUnique(strSlice []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range strSlice {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}

View File

@@ -1,163 +0,0 @@
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 {
// allow 5 threads
threads := make(chan int, 5)
results := make(map[string]Link, len(links))
resultsMutex := sync.RWMutex{}
output := []Link{}
var wg sync.WaitGroup
for _, l := range links {
wg.Add(1)
go func(link string, w *sync.WaitGroup) {
threads <- 1 // will block if MAX threads
defer w.Done()
code, err := doHead(link, followRedirects)
l := Link{}
l.URL = link
if err != nil {
l.StatusCode = 0
l.Status = httpErrorSummary(err)
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)
}
resultsMutex.Lock()
results[link] = l
resultsMutex.Unlock()
<-threads // remove from threads
}(l, &wg)
}
wg.Wait()
for _, l := range results {
output = append(output, l)
}
return output
}
// Do a HEAD request to return HTTP status code
func doHead(link string, followRedirects bool) (int, error) {
if !tools.IsValidLinkURL(link) {
return 0, fmt.Errorf("invalid URL: %s", link)
}
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
tr := &http.Transport{
DialContext: safeDialContext(dialer),
}
if config.AllowUntrustedTLS {
// user has explicitly allowed untrusted TLS, so we will not verify it for link checks
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
}
client := http.Client{
Timeout: 10 * time.Second,
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
if !followRedirects {
return http.ErrUseLastResponse
}
if !tools.IsValidLinkURL(req.URL.String()) {
return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL)
}
return nil
},
}
req, err := http.NewRequest("HEAD", link, nil)
if err != nil {
logger.Log().Errorf("[link-check] %s", err.Error())
return 0, err
}
req.Header.Set("User-Agent", "Mailpit/"+config.Version)
res, err := client.Do(req)
if err != nil {
if res != nil {
return res.StatusCode, err
}
return 0, err
}
return res.StatusCode, nil
}
// HTTP errors include a lot more info that just the actual error, so this
// tries to take the final part of it, eg: `no such host`
func httpErrorSummary(err error) string {
var re = regexp.MustCompile(`.*: (.*)$`)
e := err.Error()
if !re.MatchString(e) {
return e
}
parts := re.FindAllStringSubmatch(e, -1)
return parts[0][len(parts[0])-1]
}
// 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))
}
}

View File

@@ -1,21 +0,0 @@
package linkcheck
// Response represents the Link check response
//
// swagger:model LinkCheckResponse
type Response struct {
// Total number of errors
Errors int `json:"Errors"`
// Tested links
Links []Link `json:"Links"`
}
// Link struct
type Link struct {
// Link URL
URL string `json:"URL"`
// HTTP status code
StatusCode int `json:"StatusCode"`
// HTTP status definition
Status string `json:"Status"`
}

View File

@@ -1,78 +0,0 @@
// Package logger handles the logging
package logger
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
// VerboseLogging for verbose logging
VerboseLogging bool
// QuietLogging shows only errors
QuietLogging bool
// NoLogging shows only fatal errors
NoLogging bool
// LogFile sets a log file
LogFile string
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}
if LogFile != "" {
file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec
if err == nil {
log.Out = file
} else {
log.Out = os.Stdout
log.Warn("Failed to log to file, using default stderr")
}
} else {
log.Out = os.Stdout
}
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i any) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(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 {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "localhost:" + s[5:]
}
return s
}

View File

@@ -1,84 +0,0 @@
package pop3
import (
"errors"
"fmt"
"net"
"strings"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
func authUser(username, password string) bool {
return auth.POP3Credentials.Match(username, password)
}
// Send a response with debug logging
func sendResponse(c net.Conn, m string) {
_, _ = fmt.Fprintf(c, "%s\r\n", m)
logger.Log().Debugf("[pop3] response: %s", m)
if strings.HasPrefix(m, "-ERR ") {
sub, _ := strings.CutPrefix(m, "-ERR ")
websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub)
}
}
// Send a response without debug logging (for data)
func sendData(c net.Conn, m string) {
_, _ = fmt.Fprintf(c, "%s\r\n", m)
}
// Get the latest 100 messages
func getMessages() ([]message, error) {
messages := []message{}
list, err := storage.List(0, 0, 100)
if err != nil {
return messages, err
}
for _, m := range list {
msg := message{}
msg.ID = m.ID
msg.Size = m.Size
messages = append(messages, msg)
}
return messages, nil
}
// POP3 TOP command returns the headers, followed by the next x lines
func getTop(id string, nr int) (string, string, error) {
var header, body string
raw, err := storage.GetMessageRaw(id)
if err != nil {
return header, body, errors.New("-ERR no such message")
}
parts := strings.SplitN(string(raw), "\r\n\r\n", 2)
header = parts[0]
lines := []string{}
if nr > 0 && len(parts) == 2 {
lines = strings.SplitN(parts[1], "\r\n", nr)
}
return header, strings.Join(lines, "\r\n"), nil
}
// cuts the line into command and arguments
func getCommand(line string) (string, []string) {
line = strings.Trim(line, "\r \n")
cmd := strings.Split(line, " ")
return cmd[0], cmd[1:]
}
func getSafeArg(args []string, nr int) (string, error) {
if nr < len(args) {
return args[nr], nil
}
return "", errors.New("-ERR out of range")
}

View File

@@ -1,406 +0,0 @@
package pop3
import (
"bytes"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"testing"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3client"
"github.com/axllent/mailpit/internal/storage"
"github.com/jhillyerd/enmime/v2"
)
var (
testingPort int
)
func TestPOP3(t *testing.T) {
t.Log("Testing POP3 server")
setup()
defer storage.Close()
// connect with bad password
t.Log("Testing invalid login")
if _, err := connectBadAuth(); err == nil {
t.Error("invalid login gained access")
return
}
t.Log("Testing valid login")
c, err := connectAuth()
if err != nil {
t.Error(err.Error())
return
}
count, size, err := c.Stat()
if err != nil {
t.Error(err.Error())
return
}
assertEqual(t, count, 0, "incorrect message count")
assertEqual(t, size, 0, "incorrect size")
// quit else we get old data
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
t.Log("Inserting 50 messages")
insertEmailData(t) // insert 50 messages
c, err = connectAuth()
if err != nil {
t.Error(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Error(err.Error())
return
}
assertEqual(t, count, 50, "incorrect message count")
t.Log("Fetching 20 messages")
for i := 1; i <= 20; i++ {
_, err := c.Retr(i)
if err != nil {
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.Error(err.Error())
return
}
}
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
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.Error(err.Error())
return
}
t.Log("Fetching message count")
count, _, err = c.Stat()
if err != nil {
t.Error(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Error(err.Error())
return
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Error(err.Error())
return
}
}
t.Log("Undeleting messages")
if err := c.Rset(); err != nil {
t.Error(err.Error())
return
}
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Error(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Error(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
}
func TestAuthentication(t *testing.T) {
// commands only allowed after authentication
authCommands := make(map[string]bool)
authCommands["STAT"] = false
authCommands["LIST"] = true
authCommands["NOOP"] = false
authCommands["RSET"] = false
authCommands["RETR 1"] = true
t.Log("Testing authenticated commands while not logged in")
setup()
defer storage.Close()
insertEmailData(t) // insert 50 messages
// non-authenticated connection
c, err := connect()
if err != nil {
t.Error(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
t.Log("Testing authenticated commands while logged in")
// authenticated connection
c, err = connectAuth()
if err != nil {
t.Error(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Error(err.Error())
return
}
}
func setup() {
if err := auth.SetPOP3Auth("username:password"); err != nil {
panic(err)
}
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
var foundPort bool
for !foundPort {
testingPort = randRange(1111, 2000)
if portFree(testingPort) {
foundPort = true
}
}
config.POP3Listen = fmt.Sprintf("localhost:%d", testingPort)
if err := storage.InitDB(); err != nil {
panic(err)
}
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
go Run()
time.Sleep(time.Second)
}
// connect and authenticate
func connectAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "password")
return c, err
}
// connect and authenticate
func connectBadAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "notPassword")
return c, err
}
// connect but do not authenticate
func connect() (*pop3client.Conn, error) {
p := pop3client.New(pop3client.Opt{
Host: "localhost",
Port: testingPort,
TLSEnabled: false,
})
c, err := p.NewConn()
if err != nil {
return c, err
}
return c, err
}
func portFree(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return false
}
if err := ln.Close(); err != nil {
panic(err)
}
return true
}
func randRange(min, max int) int {
return rand.IntN(max-min) + min
}
func insertEmailData(t *testing.T) {
for i := range 50 {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
bufBytes := buf.Bytes()
id, err := storage.Store(&bufBytes, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

View File

@@ -1,350 +0,0 @@
// Package pop3 is a simple POP3 server for Mailpit.
// By default it is disabled unless password credentials have been loaded.
//
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
package pop3
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
)
const (
// AUTHORIZATION is the initial state
AUTHORIZATION = 1
// TRANSACTION is the state after login
TRANSACTION = 2
// UPDATE is the state before closing
UPDATE = 3
)
// Run will start the POP3 server if enabled
func Run() {
if auth.POP3Credentials == nil || config.POP3Listen == "" {
// POP3 server is disabled without authentication
return
}
var listener net.Listener
var err error
if config.POP3TLSCert != "" {
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
if err2 != nil {
logger.Log().Errorf("[pop3] %s", err2.Error())
return
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cer},
MinVersion: tls.VersionTLS12,
}
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
} else {
// unencrypted
listener, err = net.Listen("tcp", config.POP3Listen)
}
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
for {
conn, err := listener.Accept()
if err != nil {
logger.Log().Errorf("[pop3] accept error: %s", err.Error())
continue
}
// run as goroutine
go handleClient(conn)
}
}
type message struct {
ID string
Size uint64
}
func handleClient(conn net.Conn) {
var (
user = ""
state = AUTHORIZATION // Start with AUTHORIZATION state
toDelete []string // Track messages marked for deletion
messages []message
)
defer func() {
if state == UPDATE {
if len(toDelete) > 0 {
if err := storage.DeleteMessages(toDelete); err != nil {
logger.Log().Errorf("[pop3] error deleting: %s", err.Error())
}
// Update web UI to remove deleted messages
websockets.Broadcast("prune", nil)
}
}
if err := conn.Close(); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
}()
reader := bufio.NewReader(conn)
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
serverName := "Mailpit"
if config.Label != "" {
serverName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
sendResponse(conn, fmt.Sprintf("+OK %s POP3 server", serverName))
// Set 10 minutes timeout according to RFC1939
timeoutDuration := 600 * time.Second
for {
// Set read deadline
if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
return
}
// Reads a line from the client
rawLine, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
logger.Log().Debugf("[pop3] client disconnected: %s", conn.RemoteAddr().String())
} else {
logger.Log().Errorf("[pop3] read error: %s", err.Error())
}
return
}
// Parses the command
cmd, args := getCommand(rawLine)
cmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
sendResponse(conn, "+OK capability list follows")
sendResponse(conn, "TOP")
sendResponse(conn, "USER")
sendResponse(conn, "UIDL")
sendResponse(conn, "IMPLEMENTATION Mailpit")
sendResponse(conn, ".")
case "USER":
if state == AUTHORIZATION {
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a user")
return
}
sendResponse(conn, "+OK")
user = args[0]
} else {
sendResponse(conn, "-ERR user already specified")
}
case "PASS":
if state == AUTHORIZATION {
if user == "" {
sendResponse(conn, "-ERR must supply a user")
return
}
if len(args) != 1 {
sendResponse(conn, "-ERR must supply a password")
return
}
pass := args[0]
if authUser(user, pass) {
sendResponse(conn, "+OK signed in")
var err error
messages, err = getMessages()
if err != nil {
logger.Log().Errorf("[pop3] %s", err.Error())
}
state = TRANSACTION
} else {
sendResponse(conn, "-ERR invalid password")
logger.Log().Warnf("[pop3] failed login: %s", user)
}
} else {
sendResponse(conn, "-ERR user not specified")
}
case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE", "RSET":
if state == TRANSACTION {
handleTransactionCommand(conn, cmd, args, messages, &toDelete)
} else {
sendResponse(conn, "-ERR user not authenticated")
}
case "QUIT":
sendResponse(conn, "+OK goodbye")
state = UPDATE
return
default:
sendResponse(conn, "-ERR unknown command")
}
}
}
func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) {
switch cmd {
case "STAT":
totalSize := uint64(0)
for _, m := range messages {
totalSize += m.Size
}
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize))
case "LIST":
totalSize := uint64(0)
for _, m := range messages {
totalSize += m.Size
}
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, ".")
}
case "UIDL":
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, ".")
}
case "RETR":
if len(args) != 1 {
sendResponse(conn, "-ERR no such message")
return
}
nr, err := strconv.Atoi(args[0])
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
raw, err := storage.GetMessageRaw(m.ID)
if err != nil {
sendResponse(conn, "-ERR no such message")
return
}
size := len(raw)
sendResponse(conn, fmt.Sprintf("+OK %d octets", size))
// When all lines of the response have been sent, a
// final line is sent, consisting of a termination octet (decimal code
// 046, ".") and a CRLF pair. If any line of the multi-line response
// begins with the termination octet, the line is "byte-stuffed" by
// pre-pending the termination octet to that line of the response.
// @see: https://www.ietf.org/rfc/rfc1939.txt
sendData(conn, strings.ReplaceAll(string(raw), "\n.", "\n.."))
sendResponse(conn, ".")
case "TOP":
arg, err := getSafeArg(args, 0)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
arg2, err := getSafeArg(args, 1)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
lines, err := strconv.Atoi(arg2)
if err != nil {
sendResponse(conn, "-ERR TOP requires two arguments")
return
}
m := messages[nr-1]
headers, body, err := getTop(m.ID, lines)
if err != nil {
sendResponse(conn, err.Error())
return
}
sendResponse(conn, "+OK top of message follows")
sendData(conn, headers+"\r\n")
sendData(conn, body)
sendResponse(conn, ".")
case "NOOP":
sendResponse(conn, "+OK")
case "DELE":
arg, _ := getSafeArg(args, 0)
nr, err := strconv.Atoi(arg)
if err != nil || nr < 1 || nr > len(messages) {
sendResponse(conn, "-ERR no such message")
return
}
m := messages[nr-1]
*toDelete = append(*toDelete, m.ID)
sendResponse(conn, "+OK message marked for deletion")
case "RSET":
*toDelete = []string{}
sendResponse(conn, "+OK")
default:
sendResponse(conn, "-ERR unknown command")
}
}

View File

@@ -1,453 +0,0 @@
// Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies.
// This is used solely for testing the POP3 server
package pop3client
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"strconv"
"strings"
"time"
)
// Client implements a Client e-mail client.
type Client struct {
opt Opt
dialer Dialer
}
// Conn is a stateful connection with the POP3 server/
type Conn struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
// Opt represents the client configuration.
type Opt struct {
// Host name
Host string `json:"host"`
// Port number
Port int `json:"port"`
// DialTimeout default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
// Dialer
Dialer Dialer `json:"-"`
// TLSEnabled sets whether SLS is enabled
TLSEnabled bool `json:"tls_enabled"`
// TLSSkipVerify skips TLS verification (ie: self-signed)
TLSSkipVerify bool `json:"tls_skip_verify"`
}
// Dialer interface
type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// MessageID contains the ID and size of an individual message.
type MessageID struct {
// ID is the numerical index (non-unique) of the message.
ID int
// Size in bytes
Size int
// UID is only present if the response is to the UIDL command.
UID string
}
var (
lineBreak = []byte("\r\n")
respOK = []byte("+OK") // `+OK` without additional info
respOKInfo = []byte("+OK ") // `+OK <info>`
respErr = []byte("-ERR") // `-ERR` without additional info
respErrInfo = []byte("-ERR ") // `-ERR <info>`
)
// New returns a new client object using an existing connection.
func New(opt Opt) *Client {
if opt.DialTimeout < time.Millisecond {
opt.DialTimeout = time.Second * 3
}
c := &Client{
opt: opt,
dialer: opt.Dialer,
}
if c.dialer == nil {
c.dialer = &net.Dialer{Timeout: opt.DialTimeout}
}
return c
}
// NewConn creates and returns live POP3 server connection.
func (c *Client) NewConn() (*Conn, error) {
var (
addr = fmt.Sprintf("%s:%d", c.opt.Host, c.opt.Port)
)
conn, err := c.dialer.Dial("tcp", addr)
if err != nil {
return nil, err
}
// No TLS.
if c.opt.TLSEnabled {
// Skip TLS host verification.
tlsCfg := tls.Config{} // #nosec
if c.opt.TLSSkipVerify {
tlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec
} else {
tlsCfg.ServerName = c.opt.Host
}
conn = tls.Client(conn, &tlsCfg)
}
pCon := &Conn{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
// Verify the connection by reading the welcome +OK greeting.
if _, err := pCon.ReadOne(); err != nil {
return nil, err
}
return pCon, nil
}
// Send sends a POP3 command to the server. The given comand is suffixed with "\r\n".
func (c *Conn) Send(b string) error {
if _, err := c.w.WriteString(b + "\r\n"); err != nil {
return err
}
return c.w.Flush()
}
// Cmd sends a command to the server. POP3 responses are either single line or multi-line.
// The first line always with -ERR in case of an error or +OK in case of a successful operation.
// OK+ is always followed by a response on the same line which is either the actual response data
// in case of single line responses, or a help message followed by multiple lines of actual response
// data in case of multiline responses.
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
func (c *Conn) Cmd(cmd string, isMulti bool, args ...any) (*bytes.Buffer, error) {
var cmdLine string
// Repeat a %v to format each arg.
if len(args) > 0 {
format := " " + strings.TrimRight(strings.Repeat("%v ", len(args)), " ")
// CMD arg1 argn ...\r\n
cmdLine = fmt.Sprintf(cmd+format, args...)
} else {
cmdLine = cmd
}
if err := c.Send(cmdLine); err != nil {
return nil, err
}
// Read the first line of response to get the +OK/-ERR status.
b, err := c.ReadOne()
if err != nil {
return nil, err
}
// Single line response.
if !isMulti {
return bytes.NewBuffer(b), err
}
buf, err := c.ReadAll()
return buf, err
}
// ReadOne reads a single line response from the conn.
func (c *Conn) ReadOne() ([]byte, error) {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
r, err := parseResp(b)
return r, err
}
// ReadAll reads all lines from the connection until the POP3 multiline terminator "." is encountered
// and returns a bytes.Buffer of all the read lines.
func (c *Conn) ReadAll() (*bytes.Buffer, error) {
buf := &bytes.Buffer{}
for {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
// "." indicates the end of a multi-line response.
if bytes.Equal(b, []byte(".")) {
break
}
if _, err := buf.Write(b); err != nil {
return nil, err
}
if _, err := buf.Write(lineBreak); err != nil {
return nil, err
}
}
return buf, nil
}
// Auth authenticates the given credentials with the server.
func (c *Conn) Auth(user, password string) error {
if err := c.User(user); err != nil {
return err
}
if err := c.Pass(password); err != nil {
return err
}
// Issue a NOOP to force the server to respond to the auth.
// Courtesy: github.com/TheCreeper/go-pop3
return c.Noop()
}
// User sends the username to the server.
func (c *Conn) User(s string) error {
_, err := c.Cmd("USER", false, s)
return err
}
// Pass sends the password to the server.
func (c *Conn) Pass(s string) error {
_, err := c.Cmd("PASS", false, s)
return err
}
// Stat returns the number of messages and their total size in bytes in the inbox.
func (c *Conn) Stat() (int, int, error) {
b, err := c.Cmd("STAT", false)
if err != nil {
return 0, 0, err
}
// count size
f := bytes.Fields(b.Bytes())
// Total number of messages.
count, err := strconv.Atoi(string(f[0]))
if err != nil {
return 0, 0, err
}
if count == 0 {
return 0, 0, nil
}
// Total size of all messages in bytes.
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return 0, 0, err
}
return count, size, nil
}
// List returns a list of (message ID, message Size) pairs.
// If the optional msgID > 0, then only that particular message is listed.
// The message IDs are sequential, 1 to N.
func (c *Conn) List(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("LIST", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("LIST", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, Size: size})
}
return out, nil
}
// Uidl returns a list of (message ID, message UID) pairs. If the optional msgID
// is > 0, then only that particular message is listed. It works like Top() but only works on
// servers that support the UIDL command. Messages size field is not available in the UIDL response.
func (c *Conn) Uidl(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("UIDL", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("UIDL", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, UID: string(f[1])})
}
return out, nil
}
// Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message.
func (c *Conn) Retr(msgID int) (*mail.Message, error) {
b, err := c.Cmd("RETR", true, msgID)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// RetrRaw downloads a message by the given msgID and returns the raw []byte
// of the entire message.
func (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) {
b, err := c.Cmd("RETR", true, msgID)
return b, err
}
// Top retrieves a message by its ID with full headers and numLines lines of the body.
func (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) {
b, err := c.Cmd("TOP", true, msgID, numLines)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// Dele deletes one or more messages. The server only executes the
// deletions after a successful Quit().
func (c *Conn) Dele(msgID ...int) error {
for _, id := range msgID {
_, err := c.Cmd("DELE", false, id)
if err != nil {
return err
}
}
return nil
}
// Rset clears the messages marked for deletion in the current session.
func (c *Conn) Rset() error {
_, err := c.Cmd("RSET", false)
return err
}
// Noop issues a do-nothing NOOP command to the server. This is useful for
// prolonging open connections.
func (c *Conn) Noop() error {
_, err := c.Cmd("NOOP", false)
return err
}
// Quit sends the QUIT command to server and gracefully closes the connection.
// Message deletions (DELE command) are only executed by the server on a graceful
// quit and close.
func (c *Conn) Quit() error {
defer func() { _ = c.conn.Close() }()
if _, err := c.Cmd("QUIT", false); err != nil {
return err
}
return nil
}
// parseResp checks if the response is an error that starts with `-ERR`
// and returns an error with the message that succeeds the error indicator.
// For success `+OK` messages, it returns the remaining response bytes.
func parseResp(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, nil
}
if bytes.Equal(b, respOK) {
return nil, nil
} else if after, ok := bytes.CutPrefix(b, respOKInfo); ok {
return after, nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if after, ok := bytes.CutPrefix(b, respErrInfo); ok {
return nil, errors.New(string(after))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
}

View File

@@ -1,191 +0,0 @@
// 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"
}
}

View File

@@ -1,123 +0,0 @@
// Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server.
// See https://en.wikipedia.org/wiki/Chaos_engineering
// See https://mailpit.axllent.org/docs/integration/chaos/
package chaos
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"github.com/axllent/mailpit/internal/logger"
)
var (
// Enabled is a flag to enable or disable support for chaos
Enabled = false
// Config is the global Chaos configuration
Config = Triggers{
Sender: Trigger{ErrorCode: 451, Probability: 0},
Recipient: Trigger{ErrorCode: 451, Probability: 0},
Authentication: Trigger{ErrorCode: 535, Probability: 0},
}
)
// Triggers for the Chaos configuration
//
// swagger:model ChaosTriggers
type Triggers struct {
// Sender trigger to fail on From, Sender
Sender Trigger
// Recipient trigger to fail on To, Cc, Bcc
Recipient Trigger
// Authentication trigger to fail while authenticating (auth must be configured)
Authentication Trigger
}
// Trigger for Chaos
//
// swagger:model ChaosTrigger
type Trigger struct {
// SMTP error code to return. The value must range from 400 to 599.
// required: true
// example: 451
ErrorCode int
// Probability (chance) of triggering the error. The value must range from 0 to 100.
// required: true
// example: 5
Probability int
}
// SetFromStruct will set a whole map of chaos configurations (ie: API)
func SetFromStruct(c Triggers) error {
if c.Sender.ErrorCode == 0 {
c.Sender.ErrorCode = 451 // default
}
if c.Recipient.ErrorCode == 0 {
c.Recipient.ErrorCode = 451 // default
}
if c.Authentication.ErrorCode == 0 {
c.Authentication.ErrorCode = 535 // default
}
if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil {
return err
}
if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil {
return err
}
if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil {
return err
}
return nil
}
// Set will set the chaos configuration for the given key (CLI & setMap())
func Set(key string, errorCode int, probability int) error {
Enabled = true
if errorCode < 400 || errorCode > 599 {
return fmt.Errorf("error code must be between 400 and 599")
}
if probability > 100 || probability < 0 {
return fmt.Errorf("probability must be between 0 and 100")
}
key = strings.ToLower(key)
switch key {
case "sender":
Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability)
case "recipient", "recipients":
Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability)
case "auth", "authentication":
Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability}
logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability)
default:
return fmt.Errorf("unknown key %s", key)
}
return nil
}
// Trigger will return whether the Chaos rule is triggered based on the configuration
// and a randomly-generated percentage value.
func (c Trigger) Trigger() (bool, int) {
if !Enabled || c.Probability == 0 {
return false, 0
}
nBig, _ := rand.Int(rand.Reader, big.NewInt(100))
// rand.IntN(100) will return 0-99, whereas probability is 1-100,
// so value must be less than (not <=) to the probability to trigger
return int(nBig.Int64()) < c.Probability, c.ErrorCode
}

View File

@@ -1,155 +0,0 @@
package smtpd
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) error {
if config.SMTPForwardConfig.Host == "" {
return nil
}
if err := forward(from, *data); err != nil {
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) {
if config.TLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
conn, err := tls.Dial("tcp", addr, tlsConf)
if err != nil {
return nil, fmt.Errorf("TLS dial error: %v", err)
}
client, err := smtp.NewClient(conn, tlsConf.ServerName)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("SMTP client error: %v", err)
}
// Note: The caller is responsible for closing the client
return client, nil
}
client, err := smtp.Dial(addr)
if err != nil {
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()
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
}
}
// Note: The caller is responsible for closing the client
return client, nil
}
// Forward will connect to a pre-configured SMTP server and send a message to one or more recipients.
func forward(from string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port)
c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr)
if err != nil {
return err
}
defer func() { _ = c.Close() }()
auth := forwardAuthFromConfig()
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if config.SMTPForwardConfig.OverrideFrom != "" {
msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("error overriding From header: %s", err.Error())
}
from = config.SMTPForwardConfig.OverrideFrom
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
to := strings.SplitSeq(config.SMTPForwardConfig.To, ",")
for addr := range to {
if err = c.Rcpt(addr); err != nil {
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
if config.SMTPForwardConfig.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())
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
}
return c.Quit()
}
// Return the SMTP forwarding authentication based on config
func forwardAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPForwardConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host)
}
if config.SMTPForwardConfig.Auth == "login" {
a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password)
}
if config.SMTPForwardConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret)
}
return a
}

View File

@@ -1,357 +0,0 @@
// Package smtpd is the SMTP daemon
package smtpd
import (
"bytes"
"fmt"
"net"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/stats"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
)
var (
// DisableReverseDNS allows rDNS to be disabled
DisableReverseDNS bool
warningResponse = regexp.MustCompile(`^4\d\d `)
errorResponse = regexp.MustCompile(`^5\d\d `)
)
// MailHandler handles the incoming message to store in the database
func mailHandler(origin net.Addr, from string, to []string, data []byte, 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, 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
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
stats.LogSMTPRejected()
return "", err
}
// check / set the Return-Path based on SMTP from
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath != from {
data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">")
if err != nil {
return "", err
}
}
messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>")
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = shortuuid.New() + "@mailpit"
// add unique ID
data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
stats.LogSMTPIgnored()
return "", nil
}
}
// if enabled, this may conditionally relay the email through to the preconfigured smtp server
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
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)
missingAddresses := []string{}
for _, a := range to {
// loop through passed email addresses to check if they are in the headers
if _, err := mail.ParseAddress(a); err == nil {
_, ok := emails[strings.ToLower(a)]
if !ok {
missingAddresses = append(missingAddresses, a)
}
} else {
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
}
}
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
bccVal := strings.Join(missingAddresses, ", ")
if hasBccHeader {
b := msg.Header.Get("Bcc")
bccVal = ", " + b
}
data, err = tools.SetMessageHeader(data, "Bcc", bccVal)
if err != nil {
return "", err
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
id, err := storage.Store(&data, smtpUser)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return "", err
}
stats.LogSMTPAccepted(len(data))
data = nil // avoid memory leaks
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return id, err
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
allow := auth.SMTPCredentials.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
}
return allow, nil
}
// Allow any username and password
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
return true, nil
}
// HandlerRcpt used to optionally restrict recipients based on `--smtp-allowed-recipients`
func handlerRcpt(remoteAddr net.Addr, from string, to string) bool {
if config.SMTPAllowedRecipientsRegexp == nil {
return true
}
result := config.SMTPAllowedRecipientsRegexp.MatchString(to)
if !result {
logger.Log().Warnf("[smtpd] rejected message to %s from %s (%s)", to, from, cleanIP(remoteAddr))
stats.LogSMTPRejected()
}
return result
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login authentication (insecure)")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any authentication (insecure)")
}
} else {
if auth.SMTPCredentials != nil {
logger.Log().Info("[smtpd] enabling login authentication")
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any authentication")
}
}
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
// Translate the smtpd verb from READ/WRITE
func verbLogTranslator(verb string) string {
if verb == "READ" {
return "received"
}
return "response"
}
func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) error {
socketAddr, perm, isSocket := tools.UnixSocket(addr)
Debug = true // to enable Mailpit logging
srv := &Server{
Addr: addr,
MsgIDHandler: handler,
HandlerRcpt: handlerRcpt,
AppName: "Mailpit",
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)
},
LogWrite: func(remoteIP, verb, line string) {
if warningResponse.MatchString(line) {
logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
websockets.BroadCastClientError("warning", "smtpd", remoteIP, line)
} else if errorResponse.MatchString(line) {
logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
websockets.BroadCastClientError("error", "smtpd", remoteIP, line)
} else {
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
}
},
}
if config.Label != "" {
srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
if config.SMTPAuthAllowInsecure {
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.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{
"CRAM-MD5": false,
"PLAIN": true,
"LOGIN": true,
}
srv.AuthHandler = authHandlerAny
}
if config.SMTPTLSCert != "" {
srv.TLSRequired = config.SMTPRequireSTARTTLS
srv.TLSListener = config.SMTPRequireTLS // if true overrules srv.TLSRequired
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
return err
}
}
if isSocket {
srv.Addr = socketAddr
srv.Protocol = "unix"
srv.SocketPerm = perm
if err := tools.PrepareSocket(srv.Addr); err != nil {
storage.Close()
return err
}
// delete the Unix socket file on exit
storage.AddTempFile(srv.Addr)
logger.Log().Infof("[smtpd] starting on %s", config.SMTPListen)
} else {
smtpType := "no encryption"
if config.SMTPTLSCert != "" {
if config.SMTPRequireTLS {
smtpType = "SSL/TLS required"
} else if config.SMTPRequireSTARTTLS {
smtpType = "STARTTLS required"
} else {
smtpType = "STARTTLS optional"
if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil {
smtpType = "STARTTLS required"
}
}
}
logger.Log().Infof("[smtpd] starting on %s (%s)", config.SMTPListen, smtpType)
}
return srv.ListenAndServe()
}
func cleanIP(i net.Addr) string {
parts := strings.Split(i.String(), ":")
return parts[0]
}
// Returns a list of all lowercased emails found in To, Cc and Bcc,
// as well as whether there is a Bcc field
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
emails := make(map[string]bool)
hasBccHeader := false
if recipients, err := h.AddressList("To"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
if recipients, err := h.AddressList("Cc"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
recipients, err := h.AddressList("Bcc")
if err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
hasBccHeader = true
}
return emails, hasBccHeader
}

View File

@@ -1,221 +0,0 @@
package smtpd
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 auto relay messages if configured
func autoRelayMessage(from string, to []string, data *[]byte) error {
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil {
filteredTo := []string{}
for _, address := range to {
if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) {
logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address)
continue
}
filteredTo = append(filteredTo, address)
}
to = filteredTo
}
if len(to) == 0 {
return nil
}
if config.SMTPRelayAll {
if err := Relay(from, to, *data); err != nil {
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 {
if config.SMTPRelayMatchingRegexp.MatchString(t) {
filtered = append(filtered, t)
}
}
if len(filtered) == 0 {
return nil
}
if err := Relay(from, filtered, *data); err != nil {
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) {
if config.TLS {
tlsConf := &tls.Config{ServerName: config.Host} // #nosec
tlsConf.InsecureSkipVerify = config.AllowInsecure
conn, err := tls.Dial("tcp", addr, tlsConf)
if err != nil {
return nil, fmt.Errorf("TLS dial error: %v", err)
}
client, err := smtp.NewClient(conn, tlsConf.ServerName)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("SMTP client error: %v", err)
}
// Note: The caller is responsible for closing the client
return client, nil
}
client, err := smtp.Dial(addr)
if err != nil {
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()
return nil, fmt.Errorf("error creating StartTLS config: %v", err)
}
}
// Note: The caller is responsible for closing the client
return client, nil
}
// Relay will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Relay(from string, to []string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
c, err := createRelaySMTPClient(config.SMTPRelayConfig, addr)
if err != nil {
return err
}
defer func() { _ = c.Close() }()
auth := relayAuthFromConfig()
if auth != nil {
if err = c.Auth(auth); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if config.SMTPRelayConfig.OverrideFrom != "" {
msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom)
if err != nil {
return fmt.Errorf("error overriding From header: %s", err.Error())
}
from = config.SMTPRelayConfig.OverrideFrom
}
if err = c.Mail(from); err != nil {
return 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 errors.WithMessage(err, "error response to DATA command")
}
if _, err := w.Write(msg); err != nil {
return errors.WithMessage(err, "error sending message")
}
if err := w.Close(); err != nil {
return errors.WithMessage(err, "error closing connection")
}
return c.Quit()
}
// Return the SMTP relay authentication based on config
func relayAuthFromConfig() smtp.Auth {
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
return a
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
username, password string
}
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{
username,
password,
}
}
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("unknown fromServer")
}
}
return nil, nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,196 +0,0 @@
// 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
}

View File

@@ -1,100 +0,0 @@
// Package postmark uses the free https://spamcheck.postmarkapp.com/
// See https://spamcheck.postmarkapp.com/doc/ for more details.
package postmark
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
)
// Response struct
type Response struct {
Success bool `json:"success"`
Message string `json:"message"` // for errors only
Score string `json:"score"`
Rules []Rule `json:"rules"`
Report string `json:"report"` // ignored
}
// Rule struct
type Rule struct {
Score string `json:"score"`
// Name not returned by postmark but rather extracted from description
Name string `json:"name"`
Description string `json:"description"`
}
// Check will post the email data to Postmark
func Check(email []byte, timeout int) (Response, error) {
r := Response{}
// '{"email":"raw dump of email", "options":"short"}'
var d struct {
// The raw dump of the email to be filtered, including all headers.
Email string `json:"email"`
// Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request.
Options string `json:"options"`
}
d.Email = string(email)
d.Options = "long"
data, err := json.Marshal(d)
if err != nil {
return r, err
}
client := http.Client{
Timeout: time.Duration(timeout) * time.Second,
}
resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json",
bytes.NewBuffer(data))
if err != nil {
return r, err
}
defer func() { _ = resp.Body.Close() }()
err = json.NewDecoder(resp.Body).Decode(&r)
// remove trailing line spaces for all lines in report
re := regexp.MustCompile("\r?\n")
lines := re.Split(r.Report, -1)
reportLines := []string{}
for _, l := range lines {
line := strings.TrimRight(l, " ")
reportLines = append(reportLines, line)
}
reportRaw := strings.Join(reportLines, "\n")
// join description lines to make a single line per rule
re2 := regexp.MustCompile("\n ")
report := re2.ReplaceAllString(reportRaw, "")
for i, rule := range r.Rules {
// populate rule name
r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report)
}
return r, err
}
// Extract the name of the test from the report as Postmark does not include this in the JSON reports
func nameFromReport(score, description, report string) string {
score = regexp.QuoteMeta(score)
description = regexp.QuoteMeta(description)
str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description)
re := regexp.MustCompile(str)
matches := re.FindAllStringSubmatch(report, 1)
if len(matches) > 0 && len(matches[0]) == 2 {
return strings.TrimSpace(matches[0][1])
}
return ""
}

View File

@@ -1,140 +0,0 @@
// Package spamassassin will return results from either a SpamAssassin server or
// Postmark's public API depending on configuration
package spamassassin
import (
"errors"
"math"
"strconv"
"strings"
"github.com/axllent/mailpit/internal/spamassassin/postmark"
"github.com/axllent/mailpit/internal/spamassassin/spamc"
)
var (
// Service to use, either "<host>:<ip>" for self-hosted SpamAssassin or "postmark"
service string
// SpamScore is the score at which a message is determined to be spam
spamScore = 5.0
// Timeout in seconds
timeout = 8
)
// Result is a SpamAssassin result
//
// swagger:model SpamAssassinResponse
type Result struct {
// Whether the message is spam or not
IsSpam bool
// If populated will return an error string
Error string
// Total spam score based on triggered rules
Score float64
// Spam rules triggered
Rules []Rule
}
// Rule struct
type Rule struct {
// Spam rule score
Score float64
// SpamAssassin rule name
Name string
// SpamAssassin rule description
Description string
}
// SetService defines which service should be used.
func SetService(s string) {
switch s {
case "postmark":
service = "postmark"
default:
service = s
}
}
// Ping returns whether a service is active or not
func Ping() error {
if service == "postmark" {
return nil
}
var client *spamc.Client
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
return client.Ping()
}
// Check will return a Result
func Check(msg []byte) (Result, error) {
r := Result{Score: 0}
if service == "" {
return r, errors.New("no SpamAssassin service defined")
}
if service == "postmark" {
res, err := postmark.Check(msg, timeout)
if err != nil {
r.Error = err.Error()
return r, nil
}
resFloat, err := strconv.ParseFloat(res.Score, 32)
if err == nil {
r.Score = round1dm(resFloat)
r.IsSpam = resFloat >= spamScore
}
r.Error = res.Message
for _, pr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(pr.Score, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = pr.Name
rule.Description = pr.Description
r.Rules = append(r.Rules, rule)
}
} else {
var client *spamc.Client
if strings.HasPrefix(service, "unix:") {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
res, err := client.Report(msg)
if err != nil {
r.Error = err.Error()
return r, nil
}
r.IsSpam = res.Score >= spamScore
r.Score = round1dm(res.Score)
r.Rules = []Rule{}
for _, sr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(sr.Points, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = sr.Name
rule.Description = sr.Description
r.Rules = append(r.Rules, rule)
}
}
return r, nil
}
// Round to one decimal place
func round1dm(n float64) float64 {
return math.Floor(n*10) / 10
}

View File

@@ -1,251 +0,0 @@
// Package spamc provides a client for the SpamAssassin spamd protocol.
// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL
//
// Modified to add timeouts from https://github.com/cgt/spamc
package spamc
import (
"bufio"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/axllent/mailpit/internal/tools"
)
// ProtoVersion is the protocol version
const ProtoVersion = "1.5"
var (
spamInfoRe = regexp.MustCompile(`(.+)\/(.+) (\d+) (.+)`)
spamMainRe = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`)
spamDetailsRe = regexp.MustCompile(`^\s?(-?[0-9\.]+)\s([a-zA-Z0-9_]*)(\W*)(.*)`)
)
// connection is like net.Conn except that it also has a CloseWrite method.
// CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some
// reason it is not present in the net.Conn interface.
type connection interface {
net.Conn
CloseWrite() error
}
// Client is a spamd client.
type Client struct {
net string
addr string
timeout int
}
// NewTCP returns a *Client that connects to spamd via the given TCP address.
func NewTCP(addr string, timeout int) *Client {
return &Client{"tcp", addr, timeout}
}
// NewUnix returns a *Client that connects to spamd via the given Unix socket.
func NewUnix(addr string) *Client {
return &Client{"unix", addr, 0}
}
// Rule represents a matched SpamAssassin rule.
type Rule struct {
Points string
Name string
Description string
}
// Result struct
type Result struct {
ResponseCode int
Message string
Spam bool
Score float64
Threshold float64
Rules []Rule
}
// dial connects to spamd through TCP or a Unix socket.
func (c *Client) dial() (connection, error) {
switch c.net {
case "tcp":
tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr)
if err != nil {
return nil, err
}
return net.DialTCP("tcp", nil, tcpAddr)
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)
}
}
// Report checks if message is spam or not, and returns score plus report
func (c *Client) Report(email []byte) (Result, error) {
output, err := c.report(email)
if err != nil {
return Result{}, err
}
return c.parseOutput(output), nil
}
func (c *Client) report(email []byte) ([]string, error) {
conn, err := c.dial()
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
return nil, err
}
bw := bufio.NewWriter(conn)
if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil {
return nil, err
}
if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil {
return nil, err
}
if _, err := bw.Write(email); err != nil {
return nil, err
}
if err := bw.Flush(); err != nil {
return nil, err
}
// Client is supposed to close its writing side of the connection
// after sending its request.
if err := conn.CloseWrite(); err != nil {
return nil, err
}
var (
lines []string
br = bufio.NewReader(conn)
)
for {
line, err := br.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
line = strings.TrimRight(line, " \t\r\n")
lines = append(lines, line)
}
// join lines, and replace multi-line descriptions with single line for each
tmp := strings.Join(lines, "\n")
re := regexp.MustCompile("\n ")
n := re.ReplaceAllString(tmp, " ")
//split lines again
return strings.Split(n, "\n"), nil
}
func (c *Client) parseOutput(output []string) Result {
var result Result
var reachedRules bool
for _, row := range output {
// header
if spamInfoRe.MatchString(row) {
res := spamInfoRe.FindStringSubmatch(row)
if len(res) == 5 {
resCode, err := strconv.Atoi(res[3])
if err == nil {
result.ResponseCode = resCode
}
result.Message = res[4]
continue
}
}
// summary
if spamMainRe.MatchString(row) {
res := spamMainRe.FindStringSubmatch(row)
if len(res) == 4 {
if tools.InArray(res[1], []string{"true", "yes"}) {
result.Spam = true
} else {
result.Spam = false
}
resFloat, err := strconv.ParseFloat(res[2], 32)
if err == nil {
result.Score = resFloat
continue
}
resFloat, err = strconv.ParseFloat(res[3], 32)
if err == nil {
result.Threshold = resFloat
continue
}
}
}
if strings.HasPrefix(row, "Content analysis details") {
reachedRules = true
continue
}
// details
if reachedRules && spamDetailsRe.MatchString(row) {
res := spamDetailsRe.FindStringSubmatch(row)
if len(res) == 5 {
rule := Rule{Points: res[1], Name: res[2], Description: res[4]}
result.Rules = append(result.Rules, rule)
}
}
}
return result
}
// Ping the spamd
func (c *Client) Ping() error {
conn, err := c.dial()
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil {
return err
}
if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil {
return err
}
if err := conn.CloseWrite(); err != nil {
return err
}
br := bufio.NewReader(conn)
for {
_, err = br.ReadSlice('\n')
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,166 +0,0 @@
// Package stats stores and returns Mailpit statistics
package stats
import (
"runtime"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"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 (
// 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 uint64
smtpAcceptedSize uint64
smtpRejected uint64
smtpIgnored uint64
)
// AppInformation struct
// swagger:model AppInformation
type AppInformation struct {
// Current Mailpit version
Version string
// Latest Mailpit version
LatestVersion string
// Database path
Database string
// Database size in bytes
DatabaseSize uint64
// Total number of messages in the database
Messages uint64
// Total number of messages in the database
Unread uint64
// Tags and message totals per tag
Tags map[string]int64
// Runtime statistics
RuntimeStats struct {
// Mailpit server uptime in seconds
Uptime uint64
// Current memory usage in bytes
Memory uint64
// Database runtime messages deleted
MessagesDeleted uint64
// Accepted runtime SMTP messages
SMTPAccepted uint64
// Total runtime accepted messages size in bytes
SMTPAcceptedSize uint64
// Rejected runtime SMTP messages
SMTPRejected uint64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
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(detectLatestVersion bool) AppInformation {
info := AppInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
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 config.DisableVersionCheck {
info.LatestVersion = "disabled"
} else if detectLatestVersion {
mu.RLock()
cacheValid := time.Now().Before(vCache.expiry)
cacheValue := vCache.value
mu.RUnlock()
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()
}
}
info.Database = config.Database
info.DatabaseSize = storage.DbSize()
info.Messages = storage.CountTotal()
info.Unread = storage.CountUnread()
info.Tags = storage.GetAllTagsCount()
return info
}
// Track will start the statistics logging in memory
func Track() {
startedAt = time.Now()
}
// LogSMTPAccepted logs a successful SMTP transaction
func LogSMTPAccepted(size int) {
mu.Lock()
smtpAccepted = smtpAccepted + 1
smtpAcceptedSize = smtpAcceptedSize + tools.SafeUint64(size)
mu.Unlock()
}
// LogSMTPRejected logs a rejected SMTP transaction
func LogSMTPRejected() {
mu.Lock()
smtpRejected = smtpRejected + 1
mu.Unlock()
}
// LogSMTPIgnored logs an ignored SMTP transaction
func LogSMTPIgnored() {
mu.Lock()
smtpIgnored = smtpIgnored + 1
mu.Unlock()
}

View File

@@ -1,213 +0,0 @@
package storage
import (
"context"
"database/sql"
"math"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
// Database cron runs every minute
func dbCron() {
for {
time.Sleep(60 * time.Second)
currentTime := time.Now()
sinceLastDbAction := currentTime.Sub(dbLastAction)
// only run the database has been idle for 5 minutes
if math.Floor(sinceLastDbAction.Minutes()) == 5 {
deletedSize := getDeletedSize()
if deletedSize > 0 {
total := totalMessagesSize()
var deletedPercent float64
if total == 0 {
deletedPercent = 100
} else {
deletedPercent = float64(deletedSize * 100 / total)
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
vacuumDb()
}
}
}
pruneMessages()
}
}
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
// Set config.MaxMessages to 0 to disable.
func pruneMessages() {
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
return
}
start := time.Now()
ids := []string{}
var prunedSize uint64
var size float64 // use float64 for rqlite compatibility
// prune using `--max` if set
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) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
prunedSize = prunedSize + uint64(size)
},
); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}
// prune using `--max-age` if set
if config.MaxAgeInHours > 0 {
// now() minus the number of hours
ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
Where("Created < ?", ts).
Limit(5000)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if !tools.InArray(id, ids) {
ids = append(ids, id)
prunedSize = prunedSize + uint64(size)
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}
if len(ids) == 0 {
return
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
// roll back if it fails
defer func() { _ = tx.Rollback() }()
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox_data")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Exec(`DELETE FROM `+tenant("message_tags")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err = tx.Commit(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
if err := pruneUnusedTags(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
addDeletedSize(prunedSize)
dbLastAction = time.Now()
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
logMessagesDeleted(len(ids))
if config.DemoMode {
vacuumDb()
}
websockets.Broadcast("prune", nil)
}
// Vacuum the database to reclaim space from deleted messages
func vacuumDb() {
if sqlDriver == "rqlite" {
// let rqlite handle vacuuming
return
}
start := time.Now()
// set WAL file checkpoint
if _, err := db.Exec("PRAGMA wal_checkpoint"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
// vacuum database
if _, err := db.Exec("VACUUM"); err != nil {
logger.Log().Errorf("[db] VACUUM: %s", err.Error())
return
}
// truncate WAL file
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] vacuum completed in %s", elapsed)
}

View File

@@ -1,268 +0,0 @@
// Package storage handles all database actions
package storage
import (
"context"
"database/sql"
"fmt"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
// sqlite - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite"
// rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/
_ "github.com/rqlite/gorqlite/stdlib"
)
var (
db *sql.DB
sqlDriver string
dbLastAction time.Time
// zstd compression encoder & decoder
dbEncoder *zstd.Encoder
dbDecoder, _ = zstd.NewReader(nil)
temporaryFiles = []string{}
)
// InitDB will initialise the database
func InitDB() error {
// dbEncoder
var (
dsn string
err error
)
if config.Compression > 0 {
var compression zstd.EncoderLevel
switch config.Compression {
case 1:
compression = zstd.SpeedFastest
case 2:
compression = zstd.SpeedDefault
case 3:
compression = zstd.SpeedBestCompression
}
dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression))
if err != nil {
return err
}
logger.Log().Debugf("[db] storing messages with compression: %s", compression.String())
} else {
logger.Log().Debug("[db] storing messages with no compression")
}
p := config.Database
if p == "" {
// when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
// delete the Unix socket file on exit
AddTempFile(p)
sqlDriver = "sqlite"
dsn = p
logger.Log().Debugf("[db] using temporary database: %s", p)
} else if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
sqlDriver = "rqlite"
dsn = p
logger.Log().Debugf("[db] opening rqlite database %s", p)
} else {
p = filepath.Clean(p)
sqlDriver = "sqlite"
dsn = fmt.Sprintf("file:%s?cache=shared", p)
logger.Log().Debugf("[db] opening database %s", p)
}
config.Database = p
if sqlDriver == "sqlite" {
if !isFile(p) {
// try create a file to ensure permissions
f, err := os.Create(p)
if err != nil {
return fmt.Errorf("[db] %s", err.Error())
}
_ = f.Close()
}
}
db, err = sql.Open(sqlDriver, dsn)
if err != nil {
return err
}
for i := 1; i < 6; i++ {
if err := Ping(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
time.Sleep(5 * time.Second)
} else {
break
}
}
// prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1)
if sqlDriver == "sqlite" {
if config.DisableWAL {
// disable WAL mode for SQLite, allows NFS mounted DBs
_, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;")
} else {
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
}
if err != nil {
return err
}
}
// create tables if necessary & apply migrations
if err := dbApplySchemas(); err != nil {
return err
}
LoadTagFilters()
dbLastAction = time.Now()
sigs := make(chan os.Signal, 1)
// catch all signals since not explicitly listing
// Program that will listen to the SIGINT and SIGTERM
// SIGINT will listen to CTRL-C.
// SIGTERM will be caught if kill command executed
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
// method invoked upon seeing signal
go func() {
s := <-sigs
fmt.Printf("[db] got %s signal, shutting down\n", s)
Close()
os.Exit(0)
}()
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
// Tenant applies an optional prefix to the table name
func tenant(table string) string {
return fmt.Sprintf("%s%s", config.TenantID, table)
}
// Close will close the database, and delete if temporary
func Close() {
// on a fatal exit (eg: ports blocked), allow Mailpit to run migration tasks before closing the DB
time.Sleep(200 * time.Millisecond)
if db != nil {
if err := db.Close(); err != nil {
logger.Log().Warn("[db] error closing database, ignoring")
}
}
// allow SQLite to finish closing DB & write WAL logs if local
time.Sleep(100 * time.Millisecond)
// delete all temporary files
deleteTempFiles()
}
// Ping the database connection and return an error if unsuccessful
func Ping() error {
return db.Ping()
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
total = CountTotal()
unread = CountUnread()
tags = GetAllTags()
)
dbLastAction = time.Now()
return MailboxStats{
Total: total,
Unread: unread,
Tags: tags,
}
}
// CountTotal returns the number of emails in the database
func CountTotal() uint64 {
var total float64 // use float64 for rqlite compatibility
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
return uint64(total)
}
// CountUnread returns the number of emails in the database that are unread.
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 uint64(total)
}
// CountRead returns the number of emails in the database that are read.
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 uint64(total)
}
// DbSize returns the size of the SQLite database.
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 uint64(total.Float64)
}
// MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool {
var total int
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("MessageID = ?", id).
QueryRowAndClose(context.TODO(), db)
return total != 0
}

View File

@@ -1,69 +0,0 @@
package storage
import (
"fmt"
"os"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
var (
testTextEmail []byte
testTagEmail []byte
testMimeEmail []byte
testRuns = 100
)
func setup(tenantID string) {
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
config.TenantID = config.DBTenantID(tenantID)
if err := InitDB(); err != nil {
panic(err)
}
var err error
// ensure DB is empty
if err := DeleteAllMessages(); err != nil {
panic(err)
}
testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
if err != nil {
panic(err)
}
testTagEmail, err = os.ReadFile("testdata/tags.eml")
if err != nil {
panic(err)
}
testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml")
if err != nil {
panic(err)
}
}
func assertEqual(t *testing.T, a any, b any, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if uint64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
}
if uint64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
}
}

View File

@@ -1,815 +0,0 @@
package storage
import (
"bytes"
"context"
"crypto/md5" // #nosec
"crypto/sha1" // #nosec
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime/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, username *string) (string, error) {
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
// Parse message body with enmime
env, err := parser.ReadEnvelope(bytes.NewReader(*body))
if err != nil {
logger.Log().Warnf("[message] %s", err.Error())
return "", nil
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := 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()
// use message date instead of created date
if config.UseMessageDates {
if mDate, err := env.Date(); err == nil {
created = mDate
}
}
// generate the search text
searchText := createSearchText(env)
// generate unique ID
id := shortuuid.New()
summaryJSON, err := json.Marshal(obj)
if err != nil {
return "", err
}
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
// roll back if it fails
defer func() { _ = tx.Rollback() }()
subject := env.GetHeader("Subject")
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,?)`,
tenant("mailbox"),
) // #nosec
// insert mail summary data
_, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
if err != nil {
return "", err
}
if config.Compression > 0 {
// insert compressed raw message
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
// string and then generate the SQL query, which is more memory intensive, especially with large messages
hexStr := hex.EncodeToString(compressed)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant("mailbox_data"), hexStr), id) // #nosec
} else {
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec
}
} 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
}
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
if !config.TagsDisableXTags {
xTagsHdr := env.GetHeader("X-Tags")
if xTagsHdr != "" {
// extract tags from X-Tags header
tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...)
}
}
if !config.TagsDisablePlus {
// get tags from plus-addresses
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)...))
setTags := []string{}
if len(tags) > 0 {
setTags, err = SetMessageTags(id, tags)
if err != nil {
return "", err
}
}
c := &MessageSummary{}
if err := json.Unmarshal(summaryJSON, c); err != nil {
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
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = setTags
c.Snippet = snippet
websockets.Broadcast("new", c)
webhook.Send(c)
dbLastAction = time.Now()
BroadcastMailboxStats()
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, size)
return id, nil
}
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
OrderBy("m.Created DESC")
if limit > 0 {
q = q.Limit(limit).Offset(start)
}
if beforeTS > 0 {
q = q.Where("Created < ?", beforeTS)
}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64 // use float64 for rqlite compatibility
var id string
var messageID string
var subject string
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
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(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 = uint64(size)
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
// artificially generate ReplyTo if legacy data is missing Reply-To field
if em.ReplyTo == nil {
em.ReplyTo = []*mail.Address{}
}
results = append(results, em)
}); err != nil {
return results, err
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
}
dbLastAction = time.Now()
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] list INBOX in %s", elapsed)
return results, nil
}
// GetMessage returns a Message generated from the mailbox_data collection.
// If the message lacks a date header, then the received datetime is used.
func GetMessage(id string) (*Message, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
// 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"), "<>")
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From(tenant("mailbox")).
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64 // use float64 for rqlite compatibility
if err := row.Scan(&created); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(int64(created))
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
obj := Message{
ID: id,
MessageID: messageID,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: uint64(len(raw)),
Text: env.Text,
Username: meta.Username,
}
obj.HTML = env.HTML
obj.Inline = []Attachment{}
obj.Attachments = []Attachment{}
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
}
}
// get List-Unsubscribe links if set
obj.ListUnsubscribe = ListUnsubscribe{}
obj.ListUnsubscribe.Links = []string{}
if env.GetHeader("List-Unsubscribe") != "" {
l := env.GetHeader("List-Unsubscribe")
links, err := tools.ListUnsubscribeParser(l)
obj.ListUnsubscribe.Header = l
obj.ListUnsubscribe.Links = links
if err != nil {
obj.ListUnsubscribe.Errors = err.Error()
}
obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post")
}
// mark message as read
if err := MarkRead([]string{id}); err != nil {
return &obj, err
}
dbLastAction = time.Now()
return &obj, nil
}
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i, msg string
var compressed int
q := sqlf.From(tenant("mailbox_data")).
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Select(`Compressed`).To(&compressed).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return nil, err
}
if i == "" {
return nil, errors.New("message not found")
}
var data []byte
if sqlDriver == "rqlite" && compressed == 1 {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
}
} else {
data = []byte(msg)
}
dbLastAction = time.Now()
if compressed == 1 {
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
return raw, err
}
return data, nil
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
env, err := parser.ReadEnvelope(r)
if err != nil {
return nil, err
}
for _, a := range env.Inlines {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.OtherParts {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.Attachments {
if a.PartID == partID {
return a, nil
}
}
dbLastAction = time.Now()
return nil, errors.New("attachment not found")
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = 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
}
// LatestID returns the latest message ID
//
// If a query argument is set in the request the function will return the
// latest message matching the search
func LatestID(r *http.Request) (string, error) {
var messages []MessageSummary
var err error
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
if err != nil {
return "", err
}
} else {
messages, err = List(0, 0, 1)
if err != nil {
return "", err
}
}
if len(messages) == 0 {
return "", errors.New("Message not found")
}
return messages[0].ID, nil
}
// MarkRead will mark a message as read
func MarkRead(ids []string) error {
for _, id := range ids {
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
}
BroadcastMailboxStats()
return nil
}
// MarkAllRead will mark all messages as read
func MarkAllRead() error {
var (
start = time.Now()
total = CountUnread()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(ids []string) error {
for _, id := range ids {
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
}
BroadcastMailboxStats()
return nil
}
// DeleteMessages deletes one or more messages in bulk
func DeleteMessages(ids []string) error {
if len(ids) == 0 {
return nil
}
start := time.Now()
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec
rows, err := db.Query(sql, args...)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
toDelete := []string{}
var totalSize uint64
for rows.Next() {
var id string
var size float64 // use float64 for rqlite compatibility
if err := rows.Scan(&id, &size); err != nil {
return err
}
toDelete = append(toDelete, id)
totalSize = totalSize + uint64(size)
}
if err = rows.Err(); err != nil {
return err
}
if len(toDelete) == 0 {
return nil // nothing to delete
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
args = make([]any, len(toDelete))
for i, id := range toDelete {
args[i] = id
}
tables := []string{"mailbox", "mailbox_data", "message_tags"}
for _, t := range tables {
sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1))
_, err = tx.Exec(sql, args...) // #nosec
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(totalSize)
logMessagesDeleted(len(toDelete))
_ = pruneUnusedTags()
elapsed := time.Since(start)
messages := "messages"
if len(toDelete) == 1 {
messages = "message"
}
logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed)
BroadcastMailboxStats()
// broadcast individual message deletions
for _, id := range toDelete {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
return nil
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages() error {
var (
start = time.Now()
total int
)
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
// begin a transaction to ensure both the message
// summaries and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer func() { _ = tx.Rollback() }()
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
for _, t := range tables {
sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec
_, err := tx.Exec(sql)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
vacuumDb()
dbLastAction = time.Now()
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Warnf("[db] %s", err.Error())
}
logMessagesDeleted(total)
BroadcastMailboxStats()
websockets.Broadcast("truncate", nil)
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
}

View File

@@ -1,303 +0,0 @@
package storage
import (
"os"
"testing"
"time"
"github.com/axllent/mailpit/config"
)
func TestTextEmailInserts(t *testing.T) {
setup("")
defer Close()
t.Log("Testing text email storage")
start := time.Now()
for range testRuns {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
}
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
delStart := time.Now()
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
assertEqualStats(t, 0, 0)
}
func TestMimeEmailInserts(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing mime email storage")
} else {
t.Logf("Testing mime email storage (tenant %s)", tenantID)
}
start := time.Now()
for range testRuns {
if _, err := Store(&testMimeEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
}
assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
delStart := time.Now()
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
Close()
}
}
func TestRetrieveMimeEmail(t *testing.T) {
compressionLevels := []int{0, 1, 2, 3}
for _, compressionLevel := range compressionLevels {
t.Logf("Testing compression level: %d", compressionLevel)
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
config.Compression = compressionLevel
setup(tenantID)
if tenantID == "" {
t.Log("Testing mime email retrieval")
} else {
t.Logf("Testing mime email retrieval (tenant %s)", tenantID)
}
id, err := Store(&testMimeEmail, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, 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, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
Close()
}
}
// reset compression
config.Compression = 1
}
func TestMessageSummary(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing message summary")
} else {
t.Logf("Testing message summary (tenant %s)", tenantID)
}
if _, err := Store(&testMimeEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
summaries, err := List(0, 0, 1)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "Expected 1 result")
msg := summaries[0]
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match")
assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match")
assertEqual(t, msg.Attachments, 1, "Expected 1 attachment")
assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match")
Close()
}
}
func BenchmarkImportText(b *testing.B) {
setup("")
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(&testTextEmail, nil); err != nil {
b.Log("error ", err)
b.Fail()
}
}
}
func BenchmarkImportMime(b *testing.B) {
setup("")
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(&testMimeEmail, 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(&regularAttachment, 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)
}
}

View File

@@ -1,38 +0,0 @@
package storage
import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/websockets"
)
var bcStatsDelay = false
// BroadcastMailboxStats broadcasts the total number of messages
// displayed to the web UI, as well as the total unread messages.
// The lookup is very fast (< 10ms / 100k messages under load).
// Rate limited to 4x per second.
func BroadcastMailboxStats() {
if bcStatsDelay {
return
}
bcStatsDelay = true
go func() {
time.Sleep(250 * time.Millisecond)
bcStatsDelay = false
b := struct {
Total uint64
Unread uint64
Version string
}{
Total: CountTotal(),
Unread: CountUnread(),
Version: config.Version,
}
websockets.Broadcast("stats", b)
}()
}

View File

@@ -1,145 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/mail"
"os"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime/v2"
"github.com/leporo/sqlf"
)
// ReindexAll will regenerate the search text and snippet for a message
// and update the database.
func ReindexAll() {
ids := []string{}
var i string
chunkSize := 1000
finished := 0
err := sqlf.Select("ID").To(&i).
From(tenant("mailbox")).
OrderBy("Created DESC").
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
ids = append(ids, i)
})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
os.Exit(1)
}
total := len(ids)
chunks := chunkBy(ids, chunkSize)
logger.Log().Infof("reindexing %d messages", total)
type updateStruct struct {
// ID in database
ID string
// SearchText for searching
SearchText string
// Snippet for UI
Snippet string
// Metadata info
Metadata string
}
parser := enmime.NewParser(enmime.DisableCharacterDetection(true))
for _, ids := range chunks {
updates := []updateStruct{}
for _, id := range ids {
raw, err := GetMessageRaw(id)
if err != nil {
logger.Log().Error(err)
continue
}
r := bytes.NewReader(raw)
env, err := parser.ReadEnvelope(r)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
meta, _ := GetMetadata(id)
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
meta.From = fromJSON[0]
} else if 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")
MetadataJSON, err := json.Marshal(meta)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
searchText := createSearchText(env)
snippet := tools.CreateSnippet(env.Text, env.HTML)
u := updateStruct{}
u.ID = id
u.SearchText = searchText
u.Snippet = snippet
u.Metadata = string(MetadataJSON)
updates = append(updates, u)
}
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
// roll back if it fails
defer func() { _ = tx.Rollback() }()
// insert mail summary data
for _, u := range updates {
_, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
}
if err := tx.Commit(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
finished += len(updates)
logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total)
}
}
func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) {
for chunkSize < len(items) {
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
}
return append(chunks, items)
}

View File

@@ -1,152 +0,0 @@
package storage
import (
"bytes"
"embed"
"log"
"path"
"sort"
"strings"
"text/template"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
)
//go:embed schemas/*
var schemaScripts embed.FS
// Create tables and apply schemas if required
func dbApplySchemas() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {
return err
}
var legacyMigrationTable int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable)
if err != nil {
return err
}
if legacyMigrationTable == 1 {
rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations"))
if err != nil {
return err
}
legacySchemas := []string{}
for rows.Next() {
var oldID string
if err := rows.Scan(&oldID); err == nil {
legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID))
}
}
legacySchemas = semver.SortMin(legacySchemas)
for _, v := range legacySchemas {
var migrated int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated)
if err != nil {
return err
}
if migrated == 0 {
// copy to tenant("schemas")
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil {
return err
}
}
}
}
schemaFiles, err := schemaScripts.ReadDir("schemas")
if err != nil {
log.Fatal(err)
}
temp := template.New("")
temp.Funcs(
template.FuncMap{
"tenant": tenant,
},
)
type schema struct {
Name string
Semver string
}
scripts := []schema{}
for _, s := range schemaFiles {
if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") {
continue
}
schemaID := strings.TrimRight(s.Name(), ".sql")
if !semver.IsValid(schemaID) {
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())
continue
}
script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)}
scripts = append(scripts, script)
}
// sort schemas by semver, low to high
sort.Slice(scripts, func(i, j int) bool {
return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1
})
for _, s := range scripts {
var complete int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete)
if err != nil {
return err
}
if complete == 1 {
// already completed, ignore
continue
}
// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305
b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name))
if err != nil {
return err
}
// parse import script
t1, err := temp.Parse(string(b))
if err != nil {
return err
}
buf := new(bytes.Buffer)
if err := t1.Execute(buf, nil); err != nil {
return err
}
if _, err := db.Exec(buf.String()); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil {
return err
}
logger.Log().Debugf("[db] applied schema: %s", s.Name)
}
return nil
}
// These functions are used to migrate data formats/structure on startup.
func dataMigrations() {
// ensure DeletedSize has a value if empty
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
}

View File

@@ -1,19 +0,0 @@
-- CREATE TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID);

View File

@@ -1,3 +0,0 @@
-- CREATE TAGS COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@@ -1,36 +0,0 @@
-- CREATING NEW MAILBOX FORMAT
CREATE TABLE IF NOT EXISTS {{ tenant "mailboxtmp" }} (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO {{ tenant "mailboxtmp" }}
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM {{ tenant "mailbox" }};
DROP TABLE IF EXISTS {{ tenant "mailbox" }};
ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }};
CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@@ -1,6 +0,0 @@
-- DROP LEGACY MIGRATION TABLE
DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }};
-- DROP LEGACY TAGS COLUMN
DROP INDEX IF EXISTS {{ tenant "idx_tags" }};
ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags;

View File

@@ -1,22 +0,0 @@
-- Rebuild message_tags to remove FOREIGN KEY REFERENCES
PRAGMA foreign_keys=OFF;
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }};
DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }};
ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old;
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
TagID INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID);
INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old;
DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old;
PRAGMA foreign_keys=ON;

View File

@@ -1,5 +0,0 @@
-- CREATE Compressed COLUMN IN mailbox_data
ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0';
-- SET Compressed = 1 for all existing data
UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1;

View File

@@ -1,2 +0,0 @@
-- CREATE SNIPPET COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT '';

View File

@@ -1,16 +0,0 @@
-- CREATE TAG TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} (
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Name TEXT COLLATE NOCASE
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name);
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT REFERENCES {{ tenant "mailbox" }} (ID),
TagID INT REFERENCES {{ tenant "tags" }} (ID)
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID);

View File

@@ -1,7 +0,0 @@
-- CREATE SETTINGS TABLE
CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} (
Key TEXT,
Value TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key);
INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }}));

View File

@@ -1,5 +0,0 @@
# Migration scripts
- Scripts should be named using semver and have the `.sql` extension.
- Inline comments should be prefixed with a `--`
- All references to tables and indexes should be wrapped with a `{{ tenant "<name>" }}`

View File

@@ -1,549 +0,0 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
q := searchQueryBuilder(search, timezone)
if beforeTS > 0 {
q = q.Where(`Created < ?`, beforeTS)
}
var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64 // use float64 for rqlite compatibility
var id string
var messageID string
var subject string
var metadata string
var size float64 // use float64 for rqlite compatibility
var attachments int
var snippet string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = uint64(size)
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := min(nrResults, start+limit)
results = allResults[start:end]
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// SearchUnreadCount returns the number of unread messages matching a search.
// This is run one at a time to allow connected browsers to be updated.
func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) {
tsStart := time.Now()
q := searchQueryBuilder(search, timezone)
if beforeTS > 0 {
q = q.Where(`Created < ?`, beforeTS)
}
var unread float64 // use float64 for rqlite compatibility
q = q.Where("Read = 0").Select(`COUNT(*)`)
err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var ignore sql.NullString
if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
})
dbLastAction = time.Now()
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed)
return int64(unread), err
}
// DeleteSearch will delete all messages for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func DeleteSearch(search, timezone string) error {
q := searchQueryBuilder(search, timezone)
ids := []string{}
deleteSize := uint64(0)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64 // use float64 for rqlite compatibility
var id string
var messageID string
var subject string
var metadata string
var size float64 // use float64 for rqlite compatibility
var attachments int
var read int
var snippet string
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
deleteSize = deleteSize + uint64(size)
}); err != nil {
return err
}
if len(ids) > 0 {
total := len(ids)
// split ids into chunks of 1000 ids
var chunks [][]string
if total > 1000 {
chunkSize := 1000
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
for chunkSize < len(ids) {
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
}
if len(ids) > 0 {
// add remaining ids <= 1000
chunks = append(chunks, ids)
}
} else {
chunks = append(chunks, ids)
}
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer func() { _ = tx.Rollback() }()
for _, ids := range chunks {
delIDs := make([]any, len(ids))
for i, id := range ids {
delIDs[i] = id
}
sqlDelete1 := `DELETE FROM ` + tenant("mailbox") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil {
return err
}
sqlDelete2 := `DELETE FROM ` + tenant("mailbox_data") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete2, delIDs...)
if err != nil {
return err
}
sqlDelete3 := `DELETE FROM ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete3, delIDs...)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := pruneUnusedTags(); err != nil {
return err
}
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
dbLastAction = time.Now()
// broadcast changes
if len(ids) > 200 {
websockets.Broadcast("prune", nil)
} else {
for _, id := range ids {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
}
addDeletedSize(deleteSize)
logMessagesDeleted(total)
BroadcastMailboxStats()
}
return nil
}
// SetSearchReadStatus marks all messages matching the search as read or unread
func SetSearchReadStatus(search, timezone string, read bool) error {
q := searchQueryBuilder(search, timezone).Where("Read = ?", !read)
ids := []string{}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64 // use float64 for rqlite compatibility
var id string
var messageID string
var subject string
var metadata string
var size float64 // use float64 for rqlite compatibility
var attachments int
var read int
var snippet string
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
}); err != nil {
return err
}
if read {
if err := MarkRead(ids); err != nil {
return err
}
} else {
if err := MarkUnread(ids); err != nil {
return err
}
}
return nil
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
loc := time.Local
if timezone != "" {
if l, err := time.LoadLocation(timezone); err != nil {
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
} else {
loc = l
}
}
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,
m.Snippet,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,
IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON
`).
OrderBy("m.Created DESC")
for _, w := range args {
if cleanString(w) == "" {
continue
}
// lowercase search to try match search prefixes
lw := strings.ToLower(w)
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
lw = lw[1:]
}
// ignore blank searches
if len(w) == 0 {
continue
}
if strings.HasPrefix(lw, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "cc:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "bcc:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "reply-to:") {
w = cleanString(w[9:])
if w != "" {
if exclude {
q.Where("ReplyToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "addressed:") {
w = cleanString(w[10:])
arg := "%" + escPercentChar(w) + "%"
if w != "" {
if exclude {
q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg)
} else {
q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg)
}
}
} else if strings.HasPrefix(lw, "subject:") {
w = w[8:]
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "message-id:") {
w = cleanString(w[11:])
if w != "" {
if exclude {
q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "tag:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
} else {
q.Where(`m.ID IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
}
}
} else if lw == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if lw == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if lw == "is:tagged" {
if exclude {
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
} else {
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`)
}
} else if lw == "has:inline" || lw == "has:inlines" {
if exclude {
q.Where("Inline = 0")
} else {
q.Where("Inline > 0")
}
} else if lw == "has:attachment" || lw == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else if strings.HasPrefix(lw, "after:") {
w = cleanString(w[6:])
if w != "" {
t, err := dateparse.ParseIn(w, loc)
if err != nil {
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created <= ?`, timestamp)
} else {
q.Where(`m.Created >= ?`, timestamp)
}
}
}
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
if w != "" {
t, err := dateparse.ParseIn(w, loc)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created >= ?`, timestamp)
} else {
q.Where(`m.Created <= ?`, timestamp)
}
}
}
} else if strings.HasPrefix(lw, "larger:") && sizeToBytes(cleanString(w[7:])) > 0 {
w = cleanString(w[7:])
size := sizeToBytes(w)
if exclude {
q.Where("Size < ?", size)
} else {
q.Where("Size > ?", size)
}
} else if strings.HasPrefix(lw, "smaller:") && sizeToBytes(cleanString(w[8:])) > 0 {
w = cleanString(w[8:])
size := sizeToBytes(w)
if exclude {
q.Where("Size > ?", size)
} else {
q.Where("Size < ?", size)
}
} else {
// search text
if exclude {
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
} else {
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
}
}
}
return q
}
// Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m.
//
// K, k, Kb, KB, kB and kb are treated as Kilobytes.
// M, m, Mb, MB and mb are treated as Megabytes.
func sizeToBytes(v string) uint64 {
v = strings.ToLower(v)
re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`)
m := re.FindAllStringSubmatch(v, -1)
if len(m) == 0 {
return 0
}
val := fmt.Sprintf("%s%s", m[0][1], m[0][2])
unit := m[0][3]
i, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil {
return 0
}
if unit == "" {
return uint64(i)
}
if unit == "k" || unit == "kb" {
return uint64(i * 1024)
}
if unit == "m" || unit == "mb" {
return uint64(i * 1024 * 1024)
}
return 0
}

View File

@@ -1,225 +0,0 @@
package storage
import (
"bytes"
"fmt"
"math/rand/v2"
"testing"
"github.com/axllent/mailpit/config"
"github.com/jhillyerd/enmime/v2"
)
func TestSearch(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing search")
} else {
t.Logf("Testing search (tenant %s)", tenantID)
}
for i := range testRuns {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text(fmt.Appendf(nil, "This is the email body %d <jdsauk;dwqmdqw;>.", i)).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)).
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
bufBytes := buf.Bytes()
if _, err := Store(&bufBytes, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 51; i++ {
// search a random something that will return a single result
uniqueSearches := []string{
fmt.Sprintf("from-%d@example.com", i),
fmt.Sprintf("from:from-%d@example.com", i),
fmt.Sprintf("to-%d@example.com", i),
fmt.Sprintf("to:to-%d@example.com", i),
fmt.Sprintf("to2-%d@example.com", i),
fmt.Sprintf("to:to2-%d@example.com", i),
fmt.Sprintf("cc-%d@example.com", i),
fmt.Sprintf("cc:cc-%d@example.com", i),
fmt.Sprintf("cc2-%d@example.com", i),
fmt.Sprintf("cc:cc2-%d@example.com", i),
fmt.Sprintf("reply-to-%d@example.com", i),
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
fmt.Sprintf("\"Subject line %d end\"", i),
fmt.Sprintf("subject:\"Subject line %d end\"", i),
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
}
searchIdx := rand.IntN(len(uniqueSearches))
search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 1, "search result expected")
assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match")
assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match")
assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match")
assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match")
assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match")
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
Close()
}
}
func TestSearchDelete100(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing search delete of 100 messages")
} else {
t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID)
}
for range 100 {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(&testMimeEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
Close()
}
}
func TestSearchDelete1100(t *testing.T) {
setup("")
defer Close()
t.Log("Testing search delete of 1100 messages")
for range 1100 {
if _, err := Store(&testTextEmail, nil); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 1100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err)
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}
func TestEscPercentChar(t *testing.T) {
tests := map[string]string{}
tests["this is a test"] = "this is a test"
tests["this is% a test"] = "this is%% a test"
tests["this is%% a test"] = "this is%%%% a test"
tests["this is%%% a test"] = "this is%%%%%% a test"
tests["%this is% a test"] = "%%this is%% a test"
tests["Ä"] = "Ä"
tests["Ä%"] = "Ä%%"
for search, expected := range tests {
res := escPercentChar(search)
assertEqual(t, res, expected, "no match")
}
}
func TestSizeToBytes(t *testing.T) {
tests := map[string]uint64{}
tests["1m"] = 1048576
tests["1mb"] = 1048576
tests["1 M"] = 1048576
tests["1 MB"] = 1048576
tests["1k"] = 1024
tests["1kb"] = 1024
tests["1 K"] = 1024
tests["1 kB"] = 1024
tests["1.5M"] = 1572864
tests["1234567890"] = 1234567890
tests["invalid"] = 0
tests["1.2.3"] = 0
tests["1.2.3M"] = 0
for search, expected := range tests {
res := sizeToBytes(search)
assertEqual(t, res, expected, "size does not match")
}
}

View File

@@ -1,76 +0,0 @@
package storage
import (
"context"
"database/sql"
"github.com/axllent/mailpit/internal/logger"
"github.com/leporo/sqlf"
)
// SettingGet returns a setting string value, blank is it does not exist
func SettingGet(k string) string {
var result sql.NullString
err := sqlf.From(tenant("settings")).
Select("Value").To(&result).
Where("Key = ?", k).
Limit(1).
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return ""
}
return result.String
}
// SettingPut sets a setting string value, inserting if new
func SettingPut(k, v string) error {
_, err := db.Exec(`INSERT INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
return err
}
// The total deleted message size as an int64 value
func getDeletedSize() 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(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0
}
return uint64(result.Float64)
}
// The total raw non-compressed messages size in bytes of all messages in the database
func totalMessagesSize() uint64 {
var result sql.NullFloat64
err := sqlf.From(tenant("mailbox")).
Select("SUM(Size)").To(&result).
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {})
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return 0
}
return uint64(result.Float64)
}
// AddDeletedSize will add the value to the DeletedSize setting
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())
}
if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}

View File

@@ -1,140 +0,0 @@
package storage
import (
"net/mail"
"time"
)
// Message data excluding physical attachments
//
// swagger:model Message
type Message struct {
// Database ID
ID string
// Message ID
MessageID string
// From address
From *mail.Address
// To addresses
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// ReplyTo addresses
ReplyTo []*mail.Address
// Return-Path
ReturnPath string
// Message subject
Subject string
// List-Unsubscribe header information
ListUnsubscribe ListUnsubscribe
// 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 uint64
// Inline message attachments
Inline []Attachment
// Message attachments
Attachments []Attachment
}
// Attachment struct for inline images and attachments
//
// swagger:model Attachment
type Attachment struct {
// Attachment part ID
PartID string
// File name
FileName string
// Content type
ContentType string
// Content ID
ContentID string
// Size in bytes
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
//
// swagger:model MessageSummary
type MessageSummary struct {
// Database ID
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
From *mail.Address
// To address
To []*mail.Address
// Cc addresses
Cc []*mail.Address
// Bcc addresses
Bcc []*mail.Address
// Reply-To address
ReplyTo []*mail.Address
// Email subject
Subject string
// 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 uint64
// Whether the message has any attachments
Attachments int
// Message snippet includes up to 250 characters
Snippet string
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total uint64
Unread uint64
Tags []string
}
// 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
// including validation of the link structure
type ListUnsubscribe struct {
// List-Unsubscribe header value
Header string
// Detected links, maximum one email and one HTTP(S) link
Links []string
// Validation errors (if any)
Errors string
// List-Unsubscribe-Post value (if set)
HeaderPost string
}

View File

@@ -1,87 +0,0 @@
package storage
import (
"context"
"database/sql"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)
// TagFilter struct
type TagFilter struct {
// Match is the user-defined match
Match string
// SQL represents the SQL equivalent of Match
SQL *sqlf.Stmt
// Tags to add on match
Tags []string
}
var tagFilters = []TagFilter{}
// LoadTagFilters loads tag filters from the config and pre-generates the SQL query
func LoadTagFilters() {
tagFilters = []TagFilter{}
for _, t := range config.TagFilters {
match := strings.TrimSpace(t.Match)
if match == "" {
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
continue
}
if len(t.Tags) == 0 {
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
continue
}
validTags := []string{}
for _, tag := range t.Tags {
tagName := tools.CleanTag(tag)
if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {
logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName)
continue
}
validTags = append(validTags, tagName)
}
if len(validTags) == 0 {
continue
}
tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")})
}
}
// TagFilterMatches returns a slice of matching tags from a message
func tagFilterMatches(id string) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
for _, f := range tagFilters {
var matchID string
q := f.SQL.Clone().Where("ID = ?", id)
if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {
var ignore sql.NullString
if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return tags
}
if matchID == id {
tags = append(tags, f.Tags...)
}
}
return tags
}

View File

@@ -1,394 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
var (
addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`)
addTagMutex sync.RWMutex
)
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) ([]string, error) {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
tagNames := []string{}
currentTags := getMessageTags(id)
origTagCount := len(currentTags)
for _, t := range applyTags {
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
continue
}
name, err := addMessageTag(id, t)
if err != nil {
return []string{}, err
}
tagNames = append(tagNames, name)
}
if origTagCount > 0 {
currentTags = getMessageTags(id)
for _, t := range currentTags {
if !tools.InArray(t, applyTags) {
if err := deleteMessageTag(id, t); err != nil {
return []string{}, err
}
}
}
}
d := struct {
ID string
Tags []string
}{ID: id, Tags: applyTags}
websockets.Broadcast("update", d)
return tagNames, nil
}
// AddMessageTag adds a tag to a message
func addMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
var tagID int
var foundName sql.NullString
q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID).
Select("Name").To(&foundName).
Where("Name = ?", name)
// if tag exists - add tag to message
if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
addTagMutex.Unlock()
// check message does not already have this tag
var exists int
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&exists).
Where("ID = ?", id).
Where("TagID = ?", tagID).
QueryRowAndClose(context.Background(), db); err != nil {
return "", err
}
if exists > 0 {
// already exists
return foundName.String, nil
}
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return foundName.String, err
}
// new tag, add to the database
if _, err := sqlf.InsertInto(tenant("tags")).
Set("Name", name).
ExecAndClose(context.TODO(), db); err != nil {
addTagMutex.Unlock()
return name, err
}
addTagMutex.Unlock()
// add tag to the message
return addMessageTag(id, name)
}
// DeleteMessageTag deletes a tag from a message
func deleteMessageTag(id, name string) error {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN `+tenant("tags")+` ON TagID=`+tenant("tags.ID")+` WHERE Name = ?)`, name).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
return pruneUnusedTags()
}
// GetAllTags returns all used tags
func GetAllTags() []string {
var tags = []string{}
var name string
if err := sqlf.
Select(`DISTINCT Name`).
From(tenant("tags")).To(&name).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
return tags
}
// GetAllTagsCount returns all used tags with their total messages
func GetAllTagsCount() map[string]int64 {
var tags = make(map[string]int64)
var name string
var total float64 // use float64 for rqlite compatibility
if err := sqlf.
Select(`Name`).To(&name).
Select(`COUNT(`+tenant("message_tags.TagID")+`) as total`).To(&total).
From(tenant("tags")).
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy(tenant("message_tags.TagID")).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags[name] = int64(total)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
return tags
}
// RenameTag renames a tag
func RenameTag(from, to string) error {
to = tools.CleanTag(to)
if to == "" || !config.ValidTagRegexp.MatchString(to) {
return fmt.Errorf("invalid tag name: %s", to)
}
if from == to {
return nil // ignore
}
var id, existsID int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, from).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", from)
}
// check if another tag by this name already exists
q = sqlf.From(tenant("tags")).
Select("ID").To(&existsID).
Where(`Name = ?`, to).
Where(`ID != ?`, id).
Limit(1)
err = q.QueryRowAndClose(context.Background(), db)
if err == nil || existsID != 0 {
return fmt.Errorf("tag already exists: %s", to)
}
q = sqlf.Update(tenant("tags")).
Set("Name", to).
Where("ID = ?", id)
_, err = q.ExecAndClose(context.Background(), db)
return err
}
// DeleteTag deleted a tag and removed all references to the tag
func DeleteTag(tag string) error {
var id int
q := sqlf.From(tenant("tags")).
Select(`ID`).To(&id).
Where(`Name = ?`, tag).
Limit(1)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("tag not found: %s", tag)
}
// delete all references
q = sqlf.DeleteFrom(tenant("message_tags")).
Where(`TagID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag references: %s", err.Error())
}
// delete tag
q = sqlf.DeleteFrom(tenant("tags")).
Where(`ID = ?`, id)
_, err = q.ExecAndClose(context.Background(), db)
if err != nil {
return fmt.Errorf("error deleting tag: %s", err.Error())
}
return nil
}
// PruneUnusedTags will delete all unused tags from the database
func pruneUnusedTags() error {
q := sqlf.From(tenant("tags")).
Select(tenant("tags.ID")+", "+tenant("tags.Name")+", COUNT("+tenant("message_tags.ID")+") as COUNT").
LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy(tenant("tags.ID"))
toDel := []int{}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var n string
var id int
var c int
if err := row.Scan(&id, &n, &c); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return
}
if c == 0 {
logger.Log().Debugf("[tags] deleting unused tag \"%s\"", n)
toDel = append(toDel, id)
}
}); err != nil {
return err
}
if len(toDel) > 0 {
for _, id := range toDel {
if _, err := sqlf.DeleteFrom(tenant("tags")).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
}
}
return nil
}
// Find tags set via --tags in raw message, useful for matching all headers etc.
// This function is largely superseded by the database searching, however this
// includes literally everything and is kept for backwards compatibility.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
str := bytes.ToLower(*message)
for _, t := range tagFilters {
if bytes.Contains(str, []byte(t.Match)) {
tags = append(tags, t.Tags...)
}
}
return tags
}
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
func (d Metadata) tagsFromPlusAddresses() []string {
tags := []string{}
for _, c := range d.To {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Cc {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Bcc {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
return tools.SetTagCasing(tags)
}
// Get message tags from the database for a given database ID
// Used when parsing a raw email.
func getMessageTags(id string) []string {
tags := []string{}
var name string
if err := sqlf.
Select(`Name`).To(&name).
From(tenant("Tags")).
LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
Where(tenant("message_tags.ID")+` = ?`, id).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[tags] %s", err.Error())
return tags
}
return tags
}
// SortedUniqueTags will return a unique slice of normalised tags
func sortedUniqueTags(s []string) []string {
tags := []string{}
added := make(map[string]bool)
if len(s) == 0 {
return tags
}
for _, p := range s {
w := tools.CleanTag(p)
if w == "" {
continue
}
lc := strings.ToLower(w)
if _, exists := added[lc]; exists {
continue
}
if config.ValidTagRegexp.MatchString(w) {
added[lc] = true
tags = append(tags, w)
} else {
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
}
}
sort.Strings(tags)
return tags
}

View File

@@ -1,196 +0,0 @@
package storage
import (
"context"
"fmt"
"slices"
"strings"
"testing"
"github.com/axllent/mailpit/config"
"github.com/leporo/sqlf"
)
func TestTags(t *testing.T) {
for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} {
tenantID = config.DBTenantID(tenantID)
setup(tenantID)
if tenantID == "" {
t.Log("Testing tags")
} else {
t.Logf("Testing tags (tenant %s)", tenantID)
}
ids := []string{}
for range 10 {
id, err := Store(&testMimeEmail, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
}
ids = append(ids, id)
}
for i := range 10 {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := range 10 {
message, err := GetMessage(ids[i])
if err != nil {
t.Log("error ", err)
t.Fail()
}
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
t.Fatal("Message tags do not match")
}
}
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err := Store(&testMimeEmail, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
}
newTags := []string{}
for i := range 20 {
// pad number with 0 to ensure they are returned alphabetically
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
}
if _, err := SetMessageTags(id, newTags); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags := getMessageTags(id)
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
// remove first tag
if err := deleteMessageTag(id, newTags[0]); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
// remove all tags
if err := deleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
// apply the same tag twice
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
if err := deleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// apply tag with invalid characters
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
if err := deleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// Check deleted message tags also prune the tags database
allTags := GetAllTags()
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err = Store(&testTagEmail, nil)
if err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
if err := deleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
Close()
}
}
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 := slices.Contains(msg.Tags, username)
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()
}

Some files were not shown because too many files have changed in this diff Show More