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
171 changed files with 5206 additions and 28940 deletions

View File

@@ -19,7 +19,7 @@ Notable changes to Mailpit will be documented in this file.
{{ range .Versions }}
{{- if .CommitGroups -}}
## [{{ .Tag.Name }}]
## {{ .Tag.Name }}
{{ if .NoteGroups -}}
{{ range .NoteGroups -}}

View File

@@ -17,24 +17,6 @@ options:
fix: Fix
# perf: Performance Improvements
# refactor: Code Refactoring
sort_by: Custom
title_order:
- Feature
- Chore
- UI
- API
- Libs
- Docker
- Security
- Fix
- Bugfix
- Docs
- Swagger
- Build
- Testing
- Test
- Tests
- Pull Requests
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [axllent]

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: "monthly"
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "docker"
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -1,45 +0,0 @@
on:
push:
branches: [ develop ]
name: Build docker edge images
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Log into GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- 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=edge-${{ 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@v4
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 }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}

View File

@@ -1,23 +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@v9.0.0
with:
days-before-issue-stale: 7
days-before-issue-close: 3
exempt-issue-labels: "enhancement,bug,javascript,docker"
stale-issue-label: "stale"
stale-issue-message: "This issue 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@v4
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@v4
- uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
# build the assets
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: 18
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,24 +1,22 @@
name: Tests
on:
pull_request:
branches: [ develop, 'feature/**' ]
branches: [ develop ]
push:
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:
matrix:
go-version: [1.21.x]
os: [ubuntu-latest, windows-latest, macos-latest]
go-version: [1.18.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v5
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
cache: false
- uses: actions/checkout@v4
- name: Run Go tests
uses: actions/cache@v4
- uses: actions/checkout@v3
- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
@@ -26,24 +24,13 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./server/pop3 ./internal/tools ./internal/html2text -v
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
- run: go test ./storage -v
- run: go test ./storage -bench=.
# build the assets
- name: Build web UI
if: startsWith(matrix.os, 'ubuntu') == true
uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 16
cache: 'npm'
- if: startsWith(matrix.os, 'ubuntu') == true
run: npm install
- if: startsWith(matrix.os, 'ubuntu') == true
run: npm run package
# validate the swagger file
- name: Validate OpenAPI definition
if: startsWith(matrix.os, 'ubuntu') == true
uses: char0n/swagger-editor-validate@v1
with:
definition-file: server/ui/api/v1/swagger.json
- 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM golang:alpine AS builder
FROM golang:alpine as builder
ARG VERSION=dev
@@ -8,23 +8,13 @@ WORKDIR /app
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 add --no-cache tzdata
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s CMD /mailpit readyz
ENTRYPOINT ["/mailpit"]

132
README.md
View File

@@ -1,109 +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
- Fast message [storing & processing](https://mailpit.axllent.org/docs/configuration/email-storage/) - ingesting 100-200 emails per second over SMTP depending on CPU, network speed & email size,
easily handling tens of thousands of emails, with automatic email pruning (by default keeping the most recent 500 emails)
- `List-Unsubscribe` syntax validation
- Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages
- 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 bash script (Linux & Mac)
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
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.
### Download static binary (Windows, Linux and Mac)
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
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
```
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,151 +0,0 @@
package cmd
import (
"bytes"
"io"
"net/mail"
"net/smtp"
"os"
"path/filepath"
"strings"
"time"
"github.com/axllent/mailpit/internal/logger"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var (
ingestRecent int
)
// ingestCmd represents the ingest command
var ingestCmd = &cobra.Command{
Use: "ingest <file|folder> ...[file|folder]",
Short: "Ingest a file or folder of emails for testing",
Long: `Ingest a file or folder of emails for testing.
This command will scan the folder for emails and deliver them via SMTP to a running
Mailpit server. Each email must be a separate file (eg: Maildir format, not mbox).
The --recent flag will only consider files with a modification date within the last X days.`,
// Hidden: true,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var count int
var total int
var per100start = time.Now()
p := message.NewPrinter(language.English)
for _, a := range args {
err := filepath.Walk(a,
func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Log().Error(err)
return nil
}
if !isFile(path) {
return nil
}
if ingestRecent > 0 && time.Since(info.ModTime()) > time.Duration(ingestRecent)*24*time.Hour {
return nil
}
f, err := os.Open(filepath.Clean(path))
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
defer f.Close() // #nosec
body, err := io.ReadAll(f)
if err != nil {
logger.Log().Errorf("%s: %s", path, err.Error())
return nil
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
logger.Log().Errorf("error parsing message body: %s", err.Error())
return nil
}
recipients := []string{}
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
recipients = append(recipients, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
recipients = append(recipients, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
recipients = append(recipients, a.Address)
}
}
if sendmail.FromAddr == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
sendmail.FromAddr = fromAddresses[0].Address
}
}
if len(recipients) == 0 {
// Bcc
recipients = []string{sendmail.FromAddr}
}
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath == "" {
if fromAddresses, err := msg.Header.AddressList("From"); err == nil {
returnPath = fromAddresses[0].Address
}
}
err = smtp.SendMail(sendmail.SMTPAddr, nil, returnPath, recipients, body)
if err != nil {
logger.Log().Errorf("error sending mail: %s (%s)", err.Error(), path)
return nil
}
count++
total++
if count%100 == 0 {
formatted := p.Sprintf("%d", total)
logger.Log().Infof("[%s] 100 messages in %s", formatted, time.Since(per100start))
per100start = time.Now()
}
return nil
})
if err != nil {
logger.Log().Error(err)
}
}
},
}
func init() {
rootCmd.AddCommand(ingestCmd)
ingestCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address")
ingestCmd.Flags().IntVarP(&ingestRecent, "recent", "r", 0, "Only ingest messages from the last X days (default all)")
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}

View File

@@ -1,76 +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(cmd *cobra.Command, args []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
proto = "https"
}
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
conf := &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// do not verify TLS in case this instance is using HTTPS
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}
client := &http.Client{Transport: conf}
res, err := client.Get(uri)
if err != nil || res.StatusCode != 200 {
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(readyzCmd)
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
if config.UITLSCert != "" {
useHTTPS = true
}
readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port")
readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)")
}

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(cmd *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,22 +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/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/server/smtpd"
"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",
@@ -27,22 +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)
}
go server.Listen()
if err := smtpd.Listen(); err != nil {
storage.Close()
logger.Log().Fatal(err.Error())
logger.Log().Error(err.Error())
os.Exit(1)
}
},
@@ -75,283 +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().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().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 API CORS Access-Control-Allow-Origin header")
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")
rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)")
// SMTP server
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPAuthAcceptAny, "smtp-auth-accept-any", config.SMTPAuthAcceptAny, "Accept any SMTP username and password, including none")
rootCmd.Flags().StringVar(&config.SMTPTLSCert, "smtp-tls-cert", config.SMTPTLSCert, "TLS certificate for SMTP (STARTTLS) - requires smtp-tls-key")
rootCmd.Flags().StringVar(&config.SMTPTLSKey, "smtp-tls-key", config.SMTPTLSKey, "TLS key for SMTP (STARTTLS) - requires smtp-tls-cert")
rootCmd.Flags().BoolVar(&config.SMTPRequireSTARTTLS, "smtp-require-starttls", config.SMTPRequireSTARTTLS, "Require SMTP client use STARTTLS")
rootCmd.Flags().BoolVar(&config.SMTPRequireTLS, "smtp-require-tls", config.SMTPRequireTLS, "Require client use SSL/TLS")
rootCmd.Flags().BoolVar(&config.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Allow insecure PLAIN & LOGIN SMTP authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().IntVar(&config.SMTPMaxRecipients, "smtp-max-recipients", config.SMTPMaxRecipients, "Maximum SMTP recipients allowed")
rootCmd.Flags().StringVar(&config.SMTPAllowedRecipients, "smtp-allowed-recipients", config.SMTPAllowedRecipients, "Only allow SMTP recipients matching a regular expression (default allow all)")
rootCmd.Flags().BoolVar(&smtpd.DisableReverseDNS, "smtp-disable-rdns", smtpd.DisableReverseDNS, "Disable SMTP reverse DNS lookups")
// SMTP relay
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP relay configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAll, "smtp-relay-all", config.SMTPRelayAll, "Auto-relay all new messages via external SMTP server (caution!)")
rootCmd.Flags().StringVar(&config.SMTPRelayMatching, "smtp-relay-matching", config.SMTPRelayMatching, "Auto-relay new messages to only matching recipients (regular expression)")
// POP3 server
rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port")
rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)")
rootCmd.Flags().StringVar(&config.POP3TLSCert, "pop3-tls-cert", config.POP3TLSCert, "Optional TLS certificate for POP3 server - requires pop3-tls-key")
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")
// Tagging
rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)")
// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
// 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.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 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().Errorf(err.Error())
}
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
config.AllowUntrustedTLS = true
}
// SMTP 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().Errorf(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_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.AllowInsecure = getEnabledFromEnv("MP_SMTP_RELAY_ALLOW_INSECURE")
config.SMTPRelayConfig.Auth = os.Getenv("MP_SMTP_RELAY_AUTH")
config.SMTPRelayConfig.Username = os.Getenv("MP_SMTP_RELAY_USERNAME")
config.SMTPRelayConfig.Password = os.Getenv("MP_SMTP_RELAY_PASSWORD")
config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET")
config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH")
config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS")
config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS")
// POP3 server
if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 {
config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR")
}
config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE")
if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil {
logger.Log().Errorf(err.Error())
}
config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT")
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
// Tagging
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
config.TagsDisable = os.Getenv("MP_TAGS_DISABLE")
// 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"))
}
}
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.Database = os.Getenv("MP_DATA_FILE")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
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,18 +1,23 @@
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()
},
}
@@ -20,17 +25,9 @@ var sendmailCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(sendmailCmd)
// 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, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored")
sendmailCmd.Flags().BoolP("long-o", "o", false, "Ignored")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored")
// 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,11 +5,21 @@ import (
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/updater"
"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",
@@ -26,10 +36,10 @@ var versionCmd = &cobra.Command{
}
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)
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil && updater.GreaterThan(latest, config.Version) {
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",
latest,
@@ -49,7 +59,7 @@ func init() {
}
func updateApp() error {
rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version)
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
if err != nil {
return err
}

View File

@@ -1,577 +1,151 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
"github.com/tg123/go-htpasswd"
)
var (
// 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
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label = ""
// DataFile for mail (optional)
DataFile string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
// VerboseLogging for console output
VerboseLogging = false
// UITLSCert file
UITLSCert string
// QuietLogging for console output (errors only)
QuietLogging = false
// UITLSKey file
UITLSKey string
// NoLogging for tests
NoLogging = false
// UIAuthFile for UI & API authentication
// UISSLCert file
UISSLCert string
// UISSLKey file
UISSLKey string
// 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
// SMTPTLSCert file
SMTPTLSCert string
// SMTPSSLCert file
SMTPSSLCert 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
// CLITagsArg is used to map the CLI args
CLITagsArg string
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
// 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
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
SMTPRelayConfig SMTPRelayConfigStruct
// SMTPStrictRFCHeaders will return an error if the email headers contain <CR><CR><LF> (\r\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
SMTPStrictRFCHeaders bool
// SMTPAllowedRecipients if set, will only accept recipients matching this regular expression
SMTPAllowedRecipients string
// SMTPAllowedRecipientsRegexp is the compiled version of SMTPAllowedRecipients
SMTPAllowedRecipientsRegexp *regexp.Regexp
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// 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
// POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address
POP3Listen = "[::]:1110"
// POP3AuthFile for POP3 authentication
POP3AuthFile string
// POP3TLSCert TLS certificate
POP3TLSCert string
// POP3TLSKey TLS certificate key
POP3TLSKey string
// EnableSpamAssassin must be either <host>:<port> or "postmark"
EnableSpamAssassin string
// WebhookURL for calling
WebhookURL string
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
ContentSecurityPolicy string
// AllowUntrustedTLS allows untrusted HTTPS connections link checking & screenshot generation
AllowUntrustedTLS bool
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
// DisableHTMLCheck DEPRECATED 2024/04/13 - kept here to display console warning only
DisableHTMLCheck = 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"`
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed
AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients
BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses
BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients
// DEPRECATED 2024/03/12
RecipientAllowlist string `yaml:"recipient-allowlist"`
}
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
cssFontRestriction := "*"
if BlockRemoteCSSAndFonts {
cssFontRestriction = "'self'"
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
}
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")
}
Label = tools.Normalize(Label)
TenantID = tools.Normalize(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
TenantID = re.ReplaceAllString(TenantID, "_")
if !strings.HasSuffix(TenantID, "_") {
TenantID = TenantID + "_"
}
}
re := regexp.MustCompile(`.*:\d+$`)
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>")
return errors.New("SMTP bind should be in the format of <ip>:<port>")
}
if !re.MatchString(HTTPListen) {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
return errors.New("HTTP bind should be in the format of <ip>:<port>")
}
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 != "" {
UITLSCert = filepath.Clean(UITLSCert)
UITLSKey = filepath.Clean(UITLSKey)
if !isFile(UITLSCert) {
return fmt.Errorf("[ui] TLS certificate not found or readable: %s", UITLSCert)
if SMTPSSLCert != "" {
if !isFile(SMTPSSLCert) {
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("[ui] TLS key not found or readable: %s", UITLSKey)
if !isFile(SMTPSSLKey) {
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
}
}
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("[smtp] You must provide both an SMTP TLS certificate and a key")
}
if SMTPTLSCert != "" {
SMTPTLSCert = filepath.Clean(SMTPTLSCert)
SMTPTLSKey = filepath.Clean(SMTPTLSKey)
if !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")
}
// POP3 server
if POP3TLSCert != "" {
POP3TLSCert = filepath.Clean(POP3TLSCert)
POP3TLSKey = filepath.Clean(POP3TLSKey)
if !isFile(POP3TLSCert) {
return fmt.Errorf("[pop3] TLS certificate not found or readable: %s", POP3TLSCert)
}
if !isFile(POP3TLSKey) {
return fmt.Errorf("[pop3] TLS key not found or readable: %s", POP3TLSKey)
}
}
if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" {
return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key")
}
if POP3Listen != "" {
_, err := net.ResolveTCPAddr("tcp", POP3Listen)
if err != nil {
return fmt.Errorf("[pop3] %s", err.Error())
}
}
if POP3AuthFile != "" {
POP3AuthFile = filepath.Clean(POP3AuthFile)
if !isFile(POP3AuthFile) {
return fmt.Errorf("[pop3] password file not found or readable: %s", POP3AuthFile)
}
b, err := os.ReadFile(POP3AuthFile)
if err != nil {
return err
}
if err := auth.SetPOP3Auth(string(b)); err != nil {
return err
}
}
// Web root
validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.@]`)
if validWebrootRe.MatchString(Webroot) {
return fmt.Errorf("invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]", Webroot)
}
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 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)
SMTPAuth = a
}
return nil
}
// Parse the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[smtp] relay configuration not found or readable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, &SMTPRelayConfig); err != nil {
return err
}
if SMTPRelayConfig.Host == "" {
return errors.New("[smtp] relay host not set")
}
// DEPRECATED 2024/03/12
if SMTPRelayConfig.RecipientAllowlist != "" {
logger.Log().Warn("[smtp] relay 'recipient-allowlist' is deprecated, use 'allowed-recipients' instead")
if SMTPRelayConfig.AllowedRecipients == "" {
SMTPRelayConfig.AllowedRecipients = SMTPRelayConfig.RecipientAllowlist
}
}
return nil
}
// Validate the SMTPRelayConfig (if Host is set)
func validateRelayConfig() error {
if SMTPRelayConfig.Host == "" {
return nil
}
if SMTPRelayConfig.Port == 0 {
SMTPRelayConfig.Port = 25 // default
}
SMTPRelayConfig.Auth = strings.ToLower(SMTPRelayConfig.Auth)
if SMTPRelayConfig.Auth == "" || SMTPRelayConfig.Auth == "none" || SMTPRelayConfig.Auth == "false" {
SMTPRelayConfig.Auth = "none"
} else if SMTPRelayConfig.Auth == "plain" {
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[smtp] relay host username or password not set for PLAIN authentication")
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("[smtp] relay host username or password not set for LOGIN authentication")
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
return fmt.Errorf("[smtp] relay host username or secret not set for CRAM-MD5 authentication")
}
} else {
return fmt.Errorf("[smtp] relay authentication method not supported: %s", SMTPRelayConfig.Auth)
}
ReleaseEnabled = true
logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
if SMTPRelayConfig.AllowedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error())
}
SMTPRelayConfig.AllowedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients)
}
if SMTPRelayConfig.BlockedRecipients != "" {
re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients)
if err != nil {
return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error())
}
SMTPRelayConfig.BlockedRecipientsRegexp = re
logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients)
}
return nil
}
// IsFile returns whether a file exists and is readable
// IsFile returns if a path is a file
func isFile(path string) bool {
f, err := os.Open(filepath.Clean(path))
defer f.Close()
return err == nil
}
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil || os.IsNotExist(err) || !info.IsDir() {
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
func isValidURL(s string) bool {
u, err := url.ParseRequestURI(s)
if err != nil {
// IsDir returns whether a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return strings.HasPrefix(u.Scheme, "http")
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"
"gopkg.in/yaml.v3"
)
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.Split(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
}

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,38 +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' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;
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()],
loader: {
".svg": "file",
".woff": "file",
".woff2": "file",
},
logLevel: "info"
}
)
if (doWatch) {
await ctx.watch()
} else {
await ctx.rebuild()
ctx.dispose()
}

94
go.mod
View File

@@ -1,67 +1,63 @@
module github.com/axllent/mailpit
go 1.21.0
toolchain go1.22.1
go 1.18
require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/axllent/semver v0.0.1
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.2.0
github.com/klauspost/compress v1.17.9
github.com/kovidgoyal/imaging v1.6.3
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0
github.com/mhale/smtpd v0.8.3
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.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.2
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.27.0
golang.org/x/text v0.16.0
golang.org/x/time v0.5.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.30.2
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.2 // 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/dustin/go-humanize v1.0.1 // 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/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/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.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/reiver/go-oi v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/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
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.22.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
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
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
)

322
go.sum
View File

@@ -1,53 +1,77 @@
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
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/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/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-20240626202925-2eda941fd024 h1:saBP362Qm7zDdDXqv61kI4rzhmLFq3Z1gx34xpl6cWE=
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/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/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kovidgoyal/imaging v1.6.3 h1:iNPpv7ygiaB/NOztc6APMT7yr9UwBS+rOZwIbAdtyY8=
github.com/kovidgoyal/imaging v1.6.3/go.mod h1:sHvcLOOVhJuto2IoNdPLEqnAUoL5ZfHEF0PpNH+882g=
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=
@@ -55,169 +79,147 @@ 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.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c=
github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw=
github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/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/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/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/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tg123/go-htpasswd v1.2.2 h1:tmNccDsQ+wYsoRfiONzIhDm5OkVHQzN3w4FOBAlN6BY=
github.com/tg123/go-htpasswd v1.2.2/go.mod h1:FcIrK0J+6zptgVwK1JDlqyajW/1B4PtuJ/FLWl7nx8A=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
github.com/stretchr/testify v1.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.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/crypto v0.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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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-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-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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
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/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.30.2 h1:IPVVkhLu5mMVnS1dQgh3h0SAACRWcVk7aoLP9Us3UCk=
modernc.org/sqlite v1.30.2/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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

@@ -7,7 +7,7 @@ set -e
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 Mailpit.\nPlease try again later.\n"
echo -ne "\nThere was an error trying to check what is the latest version of ssbak.\nPlease try again later.\n"
exit 1
fi

View File

@@ -1,90 +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
// 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
}
// 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,82 +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]interface{} `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,204 +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$`), // 78.723404
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
}
var cssInlineTests = map[string]string{
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
"css-background": "[style*=\"background:\"], [background]", // 57.407406
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
"css-border": "[style*=\"border:\"], [border]", // 86.95652
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
"css-caption-side": "[style*=\"caption-side:\"]", // 84
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
"css-direction": "[style*=\"direction:\"]", // 97.77778
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
"css-display-none": "[style*=\"display:none\"]", // 84.78261
"css-display": "[style*=\"display:\"]", // 55.555553
"css-filter": "[style*=\"filter:\"]", // 50
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
"css-float": "[style*=\"float:\"]", // 85.10638
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
"css-font": "[style*=\"font:\"]", // 95.833336
"css-gap": "[style*=\"gap:\"]", // 40
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
"css-height": "[style*=\"height:\"], [height]", // 77.08333
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
"css-important": "[style*=\"!important\"]", // 43.478264
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
"css-margin": "[style*=\"margin:\"]", // 71.42857
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
"css-outline": "[style*=\"outline:\"]", // 80.85106
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
"css-position": "[style*=\"position:\"]", // 19.56522
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
"css-rgba": "[style*=\"rgba(\"]", // 56
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
"css-transform": "[style*=\"transform:\"]", // 50
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
"css-variables": "[style*=\"variables:\"]", // 46.551727
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
"css-width": "[style*=\"width:\"], [width]", // 87.5
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
}
// 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+?{`), // 26.923073
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
}
// some CSS tests using regex for units
var cssRegexpUnitTests = map[string]*regexp.Regexp{
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
}

View File

@@ -1,217 +0,0 @@
package htmlcheck
import (
"fmt"
"io"
"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
}
for key, test := range cssInlineTests {
totalTests++
found := len(doc.Find(test).Nodes)
if found > 0 {
result, err := cie.getTest(key)
if err != nil {
return results, totalTests, err
}
result.Score.Found = found
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 !isURL(a.Val) {
// skip invalid URL
continue
}
if config.BlockRemoteCSSAndFonts {
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
return h, nil
}
resp, err := downloadToBytes(a.Val)
if err != nil {
logger.Log().Warnf("[html-check] failed to download %s", a.Val)
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
}
// DownloadToBytes returns a []byte slice from a URL
func downloadToBytes(url string) ([]byte, error) {
client := http.Client{
Timeout: 5 * time.Second,
}
// Get the link response data
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err := fmt.Errorf("Error downloading %s", url)
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
// Test if str is a URL
func isURL(str string) bool {
u, err := url.Parse(str)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}

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,201 +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]interface{}) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]interface{}) {
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
if support == "y" {
y++
s.Support = "yes"
} else if support == "n" {
n++
s.Support = "no"
} else {
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,42 +0,0 @@
package htmlcheck
import (
"sort"
"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]interface{}) {
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 {
sort.Slice(clients, func(i, j int) bool {
return clients[i] < clients[j]
})
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,90 +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(`(?m)\b(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:'!\/~+#-]*[\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 {
links := []string{}
links = append(links, linkRe.FindAllString(msg.Text, -1)...)
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,114 +0,0 @@
package linkcheck
import (
"crypto/tls"
"net/http"
"regexp"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
)
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)
} 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) {
timeout := time.Duration(10 * time.Second)
tr := &http.Transport{}
if config.AllowUntrustedTLS {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec
}
client := http.Client{
Timeout: timeout,
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if followRedirects {
return nil
}
return http.ErrUseLastResponse
},
}
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]
}

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,89 +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 interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}
// CleanIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "0.0.0.0:<port>"
func CleanIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "0.0.0.0:" + s[5:]
}
return s
}
// CleanHTTPIP returns a human-readable IP for the logging interface
// when starting services. It translates [::]:<port> to "localhost:<port>"
func CleanHTTPIP(s string) string {
re := regexp.MustCompile(`^\[\:\:\]\:\d+`)
if re.MatchString(s) {
return "localhost:" + s[5:]
}
return s
}

View File

@@ -1,448 +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 string `json:"host"`
Port int `json:"port"`
// Default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
Dialer Dialer `json:"-"`
TLSEnabled bool `json:"tls_enabled"`
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 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 ...interface{}) (*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 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 bytes.HasPrefix(b, respOKInfo) {
return bytes.TrimPrefix(b, respOKInfo), nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if bytes.HasPrefix(b, respErrInfo) {
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
}

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 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,147 +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
}
}
// SetTimeout defines the timeout
func SetTimeout(t int) {
if t > 0 {
timeout = t
}
}
// Ping returns whether a service is active or not
func Ping() error {
if service == "postmark" {
return nil
}
var client *spamc.Client
if strings.HasPrefix("unix:", service) {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
return client.Ping()
}
// Check will return a Result
func Check(msg []byte) (Result, error) {
r := Result{Score: 0}
if service == "" {
return r, errors.New("no SpamAssassin service defined")
}
if service == "postmark" {
res, err := postmark.Check(msg, timeout)
if err != nil {
r.Error = err.Error()
return r, nil
}
resFloat, err := strconv.ParseFloat(res.Score, 32)
if err == nil {
r.Score = round1dm(resFloat)
r.IsSpam = resFloat >= spamScore
}
r.Error = res.Message
for _, pr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(pr.Score, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = pr.Name
rule.Description = pr.Description
r.Rules = append(r.Rules, rule)
}
} else {
var client *spamc.Client
if strings.HasPrefix("unix:", service) {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}
res, err := client.Report(msg)
if err != nil {
r.Error = err.Error()
return r, nil
}
r.IsSpam = res.Score >= spamScore
r.Score = round1dm(res.Score)
r.Rules = []Rule{}
for _, sr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(sr.Points, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = sr.Name
rule.Description = sr.Description
r.Rules = append(r.Rules, rule)
}
}
return r, nil
}
// Round to one decimal place
func round1dm(n float64) float64 {
return math.Floor(n*10) / 10
}

View File

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

View File

@@ -1,131 +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/storage"
"github.com/axllent/mailpit/internal/updater"
)
var (
// to prevent hammering Github for latest version
latestVersionCache string
// StartedAt is set to the current ime when Mailpit starts
startedAt time.Time
mu sync.RWMutex
smtpAccepted float64
smtpAcceptedSize float64
smtpRejected float64
smtpIgnored float64
)
// 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 float64
// Total number of messages in the database
Messages float64
// Total number of messages in the database
Unread float64
// Tags and message totals per tag
Tags map[string]int64
// Runtime statistics
RuntimeStats struct {
// Mailpit server uptime in seconds
Uptime float64
// Current memory usage in bytes
Memory uint64
// Database runtime messages deleted
MessagesDeleted float64
// Accepted runtime SMTP messages
SMTPAccepted float64
// Total runtime accepted messages size in bytes
SMTPAcceptedSize float64
// Rejected runtime SMTP messages
SMTPRejected float64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
SMTPIgnored float64
}
}
// Load the current statistics
func Load() AppInformation {
info := AppInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
info.RuntimeStats.SMTPAccepted = smtpAccepted
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
info.RuntimeStats.SMTPRejected = smtpRejected
info.RuntimeStats.SMTPIgnored = smtpIgnored
if latestVersionCache != "" {
info.LatestVersion = latestVersionCache
} else {
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
latestVersionCache = latest
// clear latest version cache after 5 minutes
go func() {
time.Sleep(5 * time.Minute)
latestVersionCache = ""
}()
}
}
info.Database = config.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 + float64(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,171 +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/server/websockets"
"github.com/leporo/sqlf"
)
// Database cron runs every minute
func dbCron() {
for {
time.Sleep(60 * time.Second)
currentTime := time.Now()
sinceLastDbAction := currentTime.Sub(dbLastAction)
// only run the database has been idle for 5 minutes
if math.Floor(sinceLastDbAction.Minutes()) == 5 {
deletedSize := getDeletedSize()
if deletedSize > 0 {
total := totalMessagesSize()
var deletedPercent float64
if total == 0 {
deletedPercent = 100
} else {
deletedPercent = deletedSize * 100 / total
}
// only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 {
logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
vacuumDb()
}
}
}
pruneMessages()
}
}
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
// Set config.MaxMessages to 0 to disable.
func pruneMessages() {
if config.MaxMessages < 1 {
return
}
start := time.Now()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
var prunedSize int64
var size float64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if len(ids) == 0 {
return
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox_data")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Exec(`DELETE FROM `+tenant("message_tags")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
_, err = tx.Exec(`DELETE FROM `+tenant("mailbox")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
err = tx.Commit()
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
if err := pruneUnusedTags(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
addDeletedSize(prunedSize)
dbLastAction = time.Now()
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
logMessagesDeleted(len(ids))
websockets.Broadcast("prune", nil)
}
// Vacuum the database to reclaim space from deleted messages
func vacuumDb() {
if sqlDriver == "rqlite" {
// let rqlite handle vacuuming
return
}
start := time.Now()
// set WAL file checkpoint
if _, err := db.Exec("PRAGMA wal_checkpoint"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
// vacuum database
if _, err := db.Exec("VACUUM"); err != nil {
logger.Log().Errorf("[db] VACUUM: %s", err.Error())
return
}
// truncate WAL file
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] vacuum completed in %s", elapsed)
}

View File

@@ -1,259 +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
dbFile string
dbIsTemp bool
sqlDriver string
dbLastAction time.Time
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbDecoder, _ = zstd.NewReader(nil)
)
// InitDB will initialise the database
func InitDB() error {
p := config.Database
var dsn string
if p == "" {
// when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
dbIsTemp = true
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()
}
}
var err error
db, err = sql.Open(sqlDriver, dsn)
if err != nil {
return err
}
for i := 1; i < 6; i++ {
if err := Ping(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i)
time.Sleep(5 * time.Second)
} else {
continue
}
}
// prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1)
if sqlDriver == "sqlite" {
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
if err != nil {
return err
}
}
// create tables if necessary & apply migrations
if err := dbApplySchemas(); err != nil {
return err
}
LoadTagFilters()
dbFile = p
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)
if dbIsTemp && isFile(dbFile) {
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
if err := os.Remove(dbFile); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
}
// 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() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
return total
}
// CountUnread returns the number of emails in the database that are unread.
func CountUnread() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 0).
QueryRowAndClose(context.TODO(), db)
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() float64 {
var total float64
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
Where("Read = ?", 1).
QueryRowAndClose(context.TODO(), db)
return total
}
// DbSize returns the size of the SQLite database.
func DbSize() float64 {
var total sql.NullFloat64
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return total.Float64
}
return total.Float64
}
// IsUnread returns whether a message is unread or not.
func IsUnread(id string) bool {
var unread int
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id).
QueryRowAndClose(context.TODO(), db)
return unread == 1
}
// 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,678 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/mail"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/webhook"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
"github.com/lithammer/shortuuid/v4"
)
// Store will save an email to the database tables.
// Returns the database ID of the saved message.
func Store(body *[]byte) (string, error) {
// Parse message body with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(*body))
if err != nil {
logger.Log().Warnf("[message] %s", err.Error())
return "", nil
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := DBMailSummary{
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
}
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
created := time.Now()
// use message date instead of created date
if config.UseMessageDates {
if mDate, err := env.Date(); err == nil {
created = mDate
}
}
// generate the search text
searchText := createSearchText(env)
// generate unique ID
id := shortuuid.New()
summaryJSON, err := json.Marshal(obj)
if err != nil {
return "", err
}
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
// roll back if it fails
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := float64(len(*body))
inline := len(env.Inlines)
attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML)
sql := fmt.Sprintf(`INSERT INTO %s
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
tenant("mailbox"),
) // #nosec
// insert mail summary data
_, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
if err != nil {
return "", err
}
// insert compressed raw message
encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
hexStr := hex.EncodeToString(encoded)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
// extract tags using pre-set tag filters, empty slice if not set
tags := findTagsInRawMessage(body)
if !config.TagsDisableXTags {
xTagsHdr := env.Root.Header.Get("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()...)
}
// 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
}
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()
return id, nil
}
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
OrderBy("m.Created DESC").
Limit(limit).
Offset(start)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size float64
var attachments int
var read int
var snippet string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
em.Created = time.UnixMilli(int64(created))
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
em.Snippet = snippet
// artificially generate ReplyTo if legacy data is missing Reply-To field
if em.ReplyTo == nil {
em.ReplyTo = []*mail.Address{}
}
results = append(results, em)
}); err != nil {
return results, err
}
// set tags for listed messages only
for i, m := range results {
results[i].Tags = getMessageTags(m.ID)
}
dbLastAction = time.Now()
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] list INBOX in %s", elapsed)
return results, nil
}
// GetMessage returns a Message generated from the mailbox_data collection.
// If the message lacks a date header, then the received datetime is used.
func GetMessage(id string) (*Message, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From(tenant("mailbox")).
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
if err := row.Scan(&created); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(int64(created))
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
obj := Message{
ID: id,
MessageID: messageID,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: float64(len(raw)),
Text: env.Text,
}
obj.HTML = env.HTML
obj.Inline = []Attachment{}
obj.Attachments = []Attachment{}
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
}
}
// get List-Unsubscribe links if set
obj.ListUnsubscribe = ListUnsubscribe{}
obj.ListUnsubscribe.Links = []string{}
if env.GetHeader("List-Unsubscribe") != "" {
l := env.GetHeader("List-Unsubscribe")
links, err := tools.ListUnsubscribeParser(l)
obj.ListUnsubscribe.Header = l
obj.ListUnsubscribe.Links = links
if err != nil {
obj.ListUnsubscribe.Errors = err.Error()
}
obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post")
}
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
}
dbLastAction = time.Now()
return &obj, nil
}
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i string
var msg string
q := sqlf.From(tenant("mailbox_data")).
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return nil, err
}
if i == "" {
return nil, errors.New("message not found")
}
var data []byte
if sqlDriver == "rqlite" {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
}
} else {
data = []byte(msg)
}
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
dbLastAction = time.Now()
return raw, err
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
for _, a := range env.Inlines {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.OtherParts {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.Attachments {
if a.PartID == partID {
return a, nil
}
}
dbLastAction = time.Now()
return nil, errors.New("attachment not found")
}
// LatestID returns the latest message ID
//
// If a query argument is set in the request the function will return the
// latest message matching the search
func LatestID(r *http.Request) (string, error) {
var messages []MessageSummary
var err error
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
if err != nil {
return "", err
}
} else {
messages, err = List(0, 1)
if err != nil {
return "", err
}
}
if len(messages) == 0 {
return "", errors.New("Message not found")
}
return messages[0].ID, nil
}
// MarkRead will mark a message as read
func MarkRead(id string) error {
if !IsUnread(id) {
return nil
}
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
BroadcastMailboxStats()
return err
}
// MarkAllRead will mark all messages as read
func MarkAllRead() error {
var (
start = time.Now()
total = CountUnread()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
return nil
}
_, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
BroadcastMailboxStats()
return err
}
// DeleteMessages deletes one or more messages in bulk
func DeleteMessages(ids []string) error {
if len(ids) == 0 {
return nil
}
start := time.Now()
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec
rows, err := db.Query(sql, args...)
if err != nil {
return err
}
defer rows.Close()
toDelete := []string{}
var totalSize float64
for rows.Next() {
var id string
var size float64
if err := rows.Scan(&id, &size); err != nil {
return err
}
toDelete = append(toDelete, id)
totalSize = totalSize + size
}
if err = rows.Err(); err != nil {
return err
}
if len(toDelete) == 0 {
return nil // nothing to delete
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
args = make([]interface{}, len(toDelete))
for i, id := range toDelete {
args[i] = id
}
tables := []string{"mailbox", "mailbox_data", "message_tags"}
for _, t := range tables {
sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1))
_, err = tx.Exec(sql, args...) // #nosec
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
dbLastAction = time.Now()
addDeletedSize(int64(totalSize))
logMessagesDeleted(len(toDelete))
_ = pruneUnusedTags()
elapsed := time.Since(start)
messages := "messages"
if len(toDelete) == 1 {
messages = "message"
}
logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed)
BroadcastMailboxStats()
return nil
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages() error {
var (
start = time.Now()
total int
)
_ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db)
// begin a transaction to ensure both the message
// summaries and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
for _, t := range tables {
sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec
_, err := tx.Exec(sql)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
vacuumDb()
dbLastAction = time.Now()
if err := SettingPut("DeletedSize", "0"); err != nil {
logger.Log().Warnf("[db] %s", err.Error())
}
logMessagesDeleted(total)
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
return err
}

View File

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

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 float64
Unread float64
Version string
}{
Total: CountTotal(),
Unread: CountUnread(),
Version: config.Version,
}
websockets.Broadcast("stats", b)
}()
}

View File

@@ -1,139 +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"
"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(row *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 string
SearchText string
Snippet string
Metadata string
}
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 := enmime.ReadEnvelope(r)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
from := &mail.Address{}
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := DBMailSummary{
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
}
MetadataJSON, err := json.Marshal(obj)
if err != nil {
logger.Log().Errorf("[message] %s", err.Error())
continue
}
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 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,224 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
"log"
"path"
"sort"
"strings"
"text/template"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
"github.com/leporo/sqlf"
)
//go:embed schemas/*
var schemaScripts embed.FS
// Create tables and apply schemas if required
func dbApplySchemas() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {
return err
}
var legacyMigrationTable int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable)
if err != nil {
return err
}
if legacyMigrationTable == 1 {
rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations"))
if err != nil {
return err
}
legacySchemas := []string{}
for rows.Next() {
var oldID string
if err := rows.Scan(&oldID); err == nil {
legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID))
}
}
legacySchemas = semver.SortMin(legacySchemas)
for _, v := range legacySchemas {
var migrated int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated)
if err != nil {
return err
}
if migrated == 0 {
// copy to tenant("schemas")
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil {
return err
}
}
}
// delete legacy migration database after 01/10/2024
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
return err
}
}
}
schemaFiles, err := schemaScripts.ReadDir("schemas")
if err != nil {
log.Fatal(err)
}
temp := template.New("")
temp.Funcs(
template.FuncMap{
"tenant": tenant,
},
)
type schema struct {
Name string
Semver string
}
scripts := []schema{}
for _, s := range schemaFiles {
if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") {
continue
}
schemaID := strings.TrimRight(s.Name(), ".sql")
if !semver.IsValid(schemaID) {
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())
continue
}
script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)}
scripts = append(scripts, script)
}
// sort schemas by semver, low to high
sort.Slice(scripts, func(i, j int) bool {
return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1
})
for _, s := range scripts {
var complete int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete)
if err != nil {
return err
}
if complete == 1 {
// already completed, ignore
continue
}
// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305
b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name))
if err != nil {
return err
}
// parse import script
t1, err := temp.Parse(string(b))
if err != nil {
return err
}
buf := new(bytes.Buffer)
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")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// TODO: Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From(tenant("mailbox")).
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if _, err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

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,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,399 +0,0 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"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, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
q := searchQueryBuilder(search, timezone)
var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size float64
var attachments int
var 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 = 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 := nrResults
if nrResults >= start+limit {
end = 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
}
// 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 := float64(0)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
var messageID string
var subject string
var metadata string
var size float64
var attachments int
var read int
var snippet string
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 + 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 tx.Rollback()
for _, ids := range chunks {
delIDs := make([]interface{}, 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
}
}
err = tx.Commit()
if err := pruneUnusedTags(); err != nil {
return err
}
if err == nil {
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
}
dbLastAction = time.Now()
addDeletedSize(int64(deleteSize))
logMessagesDeleted(total)
BroadcastMailboxStats()
}
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)
if timezone != "" {
loc, err := time.LoadLocation(timezone)
if err != nil {
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
} else {
time.Local = loc
}
}
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,
m.Snippet,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON,
IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON,
IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON,
IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON
`).
OrderBy("m.Created DESC")
for _, w := range args {
if cleanString(w) == "" {
continue
}
// lowercase search to try match search prefixes
lw := strings.ToLower(w)
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
lw = lw[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
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 tags t ON mt.TagID = t.ID)`)
} else {
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
}
} else if lw == "has: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.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created <= ?`, timestamp)
} else {
q.Where(`m.Created >= ?`, timestamp)
}
}
}
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
if w != "" {
t, err := dateparse.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created >= ?`, timestamp)
} else {
q.Where(`m.Created <= ?`, timestamp)
}
}
}
} else {
// search text
if exclude {
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
} else {
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%")
}
}
}
return q
}

View File

@@ -1,182 +0,0 @@
package storage
import (
"bytes"
"fmt"
"math/rand"
"testing"
"github.com/jhillyerd/enmime"
)
func TestSearch(t *testing.T) {
setup()
defer Close()
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)).
CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)).
To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)).
ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
bufBytes := buf.Bytes()
if _, err := Store(&bufBytes); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 51; i++ {
// search a random something that will return a single result
uniqueSearches := []string{
fmt.Sprintf("from-%d@example.com", i),
fmt.Sprintf("from:from-%d@example.com", i),
fmt.Sprintf("to-%d@example.com", i),
fmt.Sprintf("to:to-%d@example.com", i),
fmt.Sprintf("to2-%d@example.com", i),
fmt.Sprintf("to:to2-%d@example.com", i),
fmt.Sprintf("cc-%d@example.com", i),
fmt.Sprintf("cc:cc-%d@example.com", i),
fmt.Sprintf("cc2-%d@example.com", i),
fmt.Sprintf("cc:cc2-%d@example.com", i),
fmt.Sprintf("reply-to-%d@example.com", i),
fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i),
fmt.Sprintf("\"Subject line %d end\"", i),
fmt.Sprintf("subject:\"Subject line %d end\"", i),
fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i),
}
searchIdx := rand.Intn(len(uniqueSearches))
search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, "", 0, 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, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), testRuns, "search results expected")
}
func TestSearchDelete100(t *testing.T) {
setup()
defer Close()
t.Log("Testing search delete of 100 messages")
for i := 0; i < 100; i++ {
if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
if _, err := Store(&testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 0, 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, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, total, 0, "0 search results expected")
}
func TestSearchDelete1100(t *testing.T) {
setup()
defer Close()
t.Log("Testing search delete of 1100 messages")
for i := 0; i < 1100; i++ {
if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
_, total, err := Search("from:sender@example.com", "", 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, 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")
}
}

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

View File

@@ -1,143 +0,0 @@
package storage
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// 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
// swagger:ignore
ListUnsubscribe ListUnsubscribe
// Message date if set, else date received
Date time.Time
// Message tags
Tags []string
// Message body text
Text string
// Message body HTML
HTML string
// Message size in bytes
Size float64
// Inline message attachments
Inline []Attachment
// Message attachments
Attachments []Attachment
}
// Attachment struct for inline 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 float64
}
// 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
// Created time
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size float64
// 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 float64
Unread float64
Tags []string
}
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
ReplyTo []*mail.Address
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = float64(len(a.Content))
return o
}
// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers
// including validation of the link structure
type ListUnsubscribe struct {
// List-Unsubscribe header value
Header string
// Detected links, maximum one email and one HTTP(S)
Links []string
// Validation errors if any
Errors string
// List-Unsubscribe-Post value if set
HeaderPost string
}

View File

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

View File

@@ -1,397 +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/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
}
}
}
}
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 deleted 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 tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
return pruneUnusedTags()
}
// DeleteAllMessageTags deleted all tags from a message
func DeleteAllMessageTags(id string) error {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
return err
}
return pruneUnusedTags()
}
// GetAllTags returns all used tags
func GetAllTags() []string {
var tags = []string{}
var name string
if err := sqlf.
Select(`DISTINCT Name`).
From(tenant("tags")).To(&name).
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
return tags
}
// GetAllTagsCount returns all used tags with their total messages
func GetAllTagsCount() map[string]int64 {
var tags = make(map[string]int64)
var name string
var total int64
if err := sqlf.
Select(`Name`).To(&name).
Select(`COUNT(`+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(row *sql.Rows) {
tags[name] = total
// tags = append(tags, name)
}); 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 DBMailSummary) tagsFromPlusAddresses() []string {
tags := []string{}
for _, c := range d.To {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Cc {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
for _, c := range d.Bcc {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
}
matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
return tools.SetTagCasing(tags)
}
// Get message tags from the database for a given database ID
// 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(row *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,130 +0,0 @@
package storage
import (
"fmt"
"strings"
"testing"
)
func TestTags(t *testing.T) {
setup()
defer Close()
t.Log("Testing tags")
ids := []string{}
for i := 0; i < 10; i++ {
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
ids = append(ids, id)
}
for i := 0; i < 10; i++ {
if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 0; i < 10; i++ {
message, err := GetMessage(ids[i])
if err != nil {
t.Log("error ", err)
t.Fail()
}
if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) {
t.Fatal("Message tags do not match")
}
}
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err := Store(&testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
newTags := []string{}
for i := 0; i < 20; i++ {
// pad number with 0 to ensure they are returned alphabetically
newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i))
}
if _, err := SetMessageTags(id, newTags); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags := getMessageTags(id)
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
// remove first tag
if err := DeleteMessageTag(id, newTags[0]); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1")
// remove all tags
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty")
// apply the same tag twice
if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// apply tag with invalid characters
if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
// Check deleted message tags also prune the tags database
allTags := GetAllTags()
assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected")
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
// test 20 tags
id, err = Store(&testTagEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
returnedTags = getMessageTags(id)
assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly")
if err := DeleteAllMessageTags(id); err != nil {
t.Log("error ", err)
t.Fail()
}
}

View File

@@ -1,49 +0,0 @@
Date: Wed, 27 Jul 2022 15:44:41 +1200
From: Sender Smith <sender+FromFag@example.com>
To: Recipient Ross <recipient+ToTag@example.com>
Cc: Recipient Ross <cc+CcTag@example.com>
Bcc: <bcc+BccTag@example.com>
Subject: Plain text message
X-Tags: X-tag1, X-tag2
Message-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost>
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
Content-Disposition: inline
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia,
fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non
hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat,
mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at
posuere libero. Fusce a gravida nibh. Nulla ac odio ex.
Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus.
Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis
sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue
ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis
eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet
orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet.
Pellentesque enim nibh, varius at ante id, consequat posuere ante.
Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra
vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec
et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque
condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet
tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus
massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor
et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur
nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum
sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel
ipsum. Cras condimentum posuere vulputate.
Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est
augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget
justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales.
Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero
venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in
nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt.
Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum
vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse
mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu
arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis
lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget
lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.

View File

@@ -1,68 +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() {
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
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 interface{}, b interface{}, 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 float64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
}
if float64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
}
}

View File

@@ -1,93 +0,0 @@
package storage
import (
"net/mail"
"os"
"regexp"
"strings"
"sync"
"github.com/axllent/mailpit/internal/html2text"
"github.com/jhillyerd/enmime"
)
var (
// for stats to prevent import cycle
mu sync.RWMutex
// StatsDeleted for counting the number of messages deleted
StatsDeleted float64
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
data, err := env.AddressList(key)
if err != nil || data == nil {
return []*mail.Address{}
}
return data
}
// Generate the search text based on some header fields (to, from, subject etc)
// and either the stripped HTML body (if exists) or text body
func createSearchText(env *enmime.Envelope) string {
var b strings.Builder
b.WriteString(env.GetHeader("From") + " ")
b.WriteString(env.GetHeader("Subject") + " ")
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
b.WriteString(env.GetHeader("Reply-To") + " ")
b.WriteString(env.GetHeader("Return-Path") + " ")
h := html2text.Strip(env.HTML, true)
if h != "" {
b.WriteString(h + " ")
} else {
b.WriteString(env.Text + " ")
}
// add attachment filenames
for _, a := range env.Attachments {
b.WriteString(a.FileName + " ")
}
d := cleanString(b.String())
return d
}
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
str = strings.ReplaceAll(str, string('\uFEFF'), " ")
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
}
// LogMessagesDeleted logs the number of messages deleted
func logMessagesDeleted(n int) {
mu.Lock()
StatsDeleted = StatsDeleted + float64(n)
mu.Unlock()
}
// IsFile returns whether 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
}
// Convert `%` to `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}

View File

@@ -1,32 +0,0 @@
package tools
import "strings"
// ArgsParser will split a string by new words and quotes phrases
func ArgsParser(s string) []string {
args := []string{}
sb := &strings.Builder{}
quoted := false
for _, r := range s {
if r == '"' {
quoted = !quoted
sb.WriteRune(r) // keep '"' otherwise comment this line
} else if !quoted && r == ' ' {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
sb.Reset()
} else {
sb.WriteRune(r)
}
}
if sb.Len() > 0 {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
}
return args
}

View File

@@ -1,19 +0,0 @@
package tools
import (
"fmt"
"golang.org/x/net/html"
)
// GetHTMLAttributeVal returns the value of an HTML Attribute, else an error.
// Returns a blank value if the attribute is set but empty.
func GetHTMLAttributeVal(e *html.Node, key string) (string, error) {
for _, a := range e.Attr {
if a.Key == key {
return a.Val, nil
}
}
return "", fmt.Errorf("%s not found", key)
}

View File

@@ -1,99 +0,0 @@
package tools
import (
"fmt"
"net/url"
"regexp"
"strings"
)
// ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return
// a slide of addresses (mail & URLs)
func ListUnsubscribeParser(v string) ([]string, error) {
var results = []string{}
var re = regexp.MustCompile(`(?mU)<(.*)>`)
var reJoins = regexp.MustCompile(`(?imUs)>(.*)<`)
var reValidJoinChars = regexp.MustCompile(`(?imUs)^(\s+)?,(\s+)?$`)
var reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`)
var reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`)
var reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`)
var reSpaces = regexp.MustCompile(`\s`)
var reComments = regexp.MustCompile(`(?mUs)\(.*\)`)
var hasMailTo bool
var hasHTTP bool
v = strings.TrimSpace(v)
comments := reComments.FindAllStringSubmatch(v, -1)
for _, c := range comments {
// strip comments
v = strings.Replace(v, c[0], "", -1)
v = strings.TrimSpace(v)
}
if !re.MatchString(v) {
return results, fmt.Errorf("\"%s\" no valid unsubscribe links found", v)
}
errors := []string{}
if !reWrapper.MatchString(v) {
return results, fmt.Errorf("\"%s\" should be enclosed in <>", v)
}
matches := re.FindAllStringSubmatch(v, -1)
if len(matches) > 2 {
errors = append(errors, fmt.Sprintf("\"%s\" should include a maximum of one email and one HTTP link", v))
} else {
splits := reJoins.FindAllStringSubmatch(v, -1)
for _, g := range splits {
if !reValidJoinChars.MatchString(g[1]) {
return results, fmt.Errorf("\"%s\" <> should be split with a comma and optional spaces", v)
}
}
for _, m := range matches {
r := m[1]
if reSpaces.MatchString(r) {
errors = append(errors, fmt.Sprintf("\"%s\" should not contain spaces", r))
continue
}
if reMailTo.MatchString(r) {
if hasMailTo {
errors = append(errors, fmt.Sprintf("\"%s\" should only contain one mailto:", r))
continue
}
hasMailTo = true
} else if reHTTP.MatchString(r) {
if hasHTTP {
errors = append(errors, fmt.Sprintf("\"%s\" should only contain one HTTP link", r))
continue
}
hasHTTP = true
} else {
errors = append(errors, fmt.Sprintf("\"%s\" should start with either http(s):// or mailto:", r))
continue
}
_, err := url.ParseRequestURI(r)
if err != nil {
errors = append(errors, err.Error())
continue
}
results = append(results, r)
}
}
var err error
if len(errors) > 0 {
err = fmt.Errorf("%s", strings.Join(errors, ", "))
}
return results, err
}

View File

@@ -1,99 +0,0 @@
// Package tools provides various methods for various things
package tools
import (
"bufio"
"bytes"
"net/mail"
"regexp"
"github.com/axllent/mailpit/internal/logger"
)
// RemoveMessageHeaders scans a message for headers, if found them removes them.
// It will only remove a single instance of any given message header.
func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) {
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
reBlank := regexp.MustCompile(`^\s+`)
for _, hdr := range headers {
// case-insensitive
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":"))
// header := []byte(hdr + ":")
if m.Header.Get(hdr) != "" {
scanner := bufio.NewScanner(bytes.NewReader(msg))
found := false
hdr := []byte("")
for scanner.Scan() {
line := scanner.Bytes()
if !found && reHdr.Match(line) {
// add the first line starting with <header>:
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
found = true
} else if found && reBlank.Match(line) {
// add any following lines starting with a whitespace (tab or space)
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
} else if found {
// stop scanning, we have the full <header>
break
}
}
if len(hdr) > 0 {
logger.Log().Debugf("[release] removed %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(""), 1)
}
}
}
return msg, nil
}
// UpdateMessageHeader scans a message for a header and updates its value if found.
func UpdateMessageHeader(msg []byte, header, value string) ([]byte, error) {
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
if m.Header.Get(header) != "" {
reBlank := regexp.MustCompile(`^\s+`)
reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(header+":"))
scanner := bufio.NewScanner(bytes.NewReader(msg))
found := false
hdr := []byte("")
for scanner.Scan() {
line := scanner.Bytes()
if !found && reHdr.Match(line) {
// add the first line starting with <header>:
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
found = true
} else if found && reBlank.Match(line) {
// add any following lines starting with a whitespace (tab or space)
hdr = append(hdr, line...)
hdr = append(hdr, []byte("\r\n")...)
} else if found {
// stop scanning, we have the full <header>
break
}
}
if len(hdr) > 0 {
logger.Log().Debugf("[release] replaced %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1)
}
}
return msg, nil
}

View File

@@ -1,44 +0,0 @@
package tools
import (
"regexp"
"strings"
"github.com/axllent/mailpit/internal/html2text"
)
// CreateSnippet returns a message snippet. It will use the HTML version (if it exists)
// otherwise the text version.
func CreateSnippet(text, html string) string {
text = strings.TrimSpace(text)
html = strings.TrimSpace(html)
limit := 200
spaceRe := regexp.MustCompile(`\s+`)
if text == "" && html == "" {
return ""
}
if html != "" {
data := html2text.Strip(html, false)
if len(data) <= limit {
return data
}
return data[0:limit] + "..."
}
if text != "" {
// replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184
text = strings.ReplaceAll(text, string('\uFEFF'), " ")
text = strings.TrimSpace(spaceRe.ReplaceAllString(text, " "))
if len(text) <= limit {
return text
}
return text[0:limit] + "..."
}
return ""
}

View File

@@ -1,47 +0,0 @@
package tools
import (
"regexp"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
// Invalid tag characters regex
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`)
// Regex to catch multiple spaces
multiSpaceRe = regexp.MustCompile(`(\s+)`)
// TagsTitleCase enforces TitleCase on all tags
TagsTitleCase bool
)
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
func CleanTag(s string) string {
return strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
}
// SetTagCasing returns the slice of tags, title-casing if set
func SetTagCasing(s []string) []string {
if !TagsTitleCase {
return s
}
titleTags := []string{}
c := cases.Title(language.Und, cases.NoLower)
for _, t := range s {
titleTags = append(titleTags, c.String(t))
}
return titleTags
}

View File

@@ -1,119 +0,0 @@
package tools
import (
"reflect"
"testing"
)
func TestArgsParser(t *testing.T) {
tests := map[string][]string{}
tests["this is a test"] = []string{"this", "is", "a", "test"}
tests["\"this is\" a test"] = []string{"this is", "a", "test"}
tests["!\"this is\" a test"] = []string{"!this is", "a", "test"}
tests["subject:this is a test"] = []string{"subject:this", "is", "a", "test"}
tests["subject:\"this is\" a test"] = []string{"subject:this is", "a", "test"}
tests["subject:\"this is\" \"a test\""] = []string{"subject:this is", "a test"}
tests["subject:\"this 'is\" \"a test\""] = []string{"subject:this 'is", "a test"}
tests["subject:\"this 'is a test"] = []string{"subject:this 'is a test"}
tests["\"this is a test\"=\"this is a test\""] = []string{"this is a test=this is a test"}
for search, expected := range tests {
res := ArgsParser(search)
if !reflect.DeepEqual(res, expected) {
t.Log("Args parser error:", res, "!=", expected)
t.Fail()
}
}
}
func TestCleanTag(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 99 IS a Test :-)"] = "thiS 99 IS a Test -"
tests["this_is-a test "] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a test"
for search, expected := range tests {
res := CleanTag(search)
if res != expected {
t.Log("CleanTags error:", res, "!=", expected)
t.Fail()
}
}
}
func TestSnippets(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["this_is-a test "] = "this_is-a test"
tests["this_is-a&^%%(*)@ test"] = "this_is-a&^%%(*)@ test"
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 linked text"
// broken html
tests[`<h1>Heading</h3><p> <a href="https://github.com">linked text.`] = "Heading linked text."
// truncation to 200 chars + ...
tests["abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789"] = "abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmno..."
for str, expected := range tests {
res := CreateSnippet(str, str)
if res != expected {
t.Log("CreateSnippet error:", res, "!=", expected)
t.Fail()
}
}
}
func TestListUnsubscribeParser(t *testing.T) {
tests := map[string]bool{}
// should pass
tests["<mailto:unsubscribe@example.com>"] = true
tests["<https://example.com>"] = true
tests["<HTTPS://EXAMPLE.COM>"] = true
tests["<mailto:unsubscribe@example.com>, <http://example.com>"] = true
tests["<mailto:unsubscribe@example.com>, <https://example.com>"] = true
tests["<https://example.com>, <mailto:unsubscribe@example.com>"] = true
tests["<https://example.com> , <mailto:unsubscribe@example.com>"] = true
tests["<https://example.com> ,<mailto:unsubscribe@example.com>"] = true
tests["<mailto:unsubscribe@example.com>,<https://example.com>"] = true
tests[`<https://example.com> ,
<mailto:unsubscribe@example.com>`] = true
tests["<mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
tests["<mailto:unsubscribe@example.com> (Use this command to get off the list)"] = true
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com>, (Click this link to unsubscribe) <http://example.com>"] = true
// should fail
tests["mailto:unsubscribe@example.com"] = false // no <>
tests["<mailto::unsubscribe@example.com>"] = false // ::
tests["https://example.com/"] = false // no <>
tests["mailto:unsubscribe@example.com, <https://example.com/>"] = false // no <>
tests["<MAILTO:unsubscribe@example.com>"] = false // capitals
tests["<mailto:unsubscribe@example.com>, <mailto:test2@example.com>"] = false // two emails
tests["<http://exampl\\e2.com>, <http://example2.com>"] = false // two links
tests["<http://example.com>, <mailto:unsubscribe@example.com>, <http://example2.com>"] = false // two links
tests["<mailto:unsubscribe@example.com>, <example.com>"] = false // no mailto || http(s)
tests["<mailto: unsubscribe@example.com>, <unsubscribe@lol.com>"] = false // space
tests["<mailto:unsubscribe@example.com?subject=unsubscribe me>"] = false // space
tests["<http:///example.com>"] = false // http:///
for search, expected := range tests {
_, err := ListUnsubscribeParser(search)
hasError := err != nil
if expected == hasError {
if err != nil {
t.Logf("ListUnsubscribeParser: %v", err)
} else {
t.Logf("ListUnsubscribeParser: \"%s\" expected: %v", search, expected)
}
t.Fail()
}
}
}

View File

@@ -1,38 +0,0 @@
package tools
import (
"fmt"
"regexp"
"strings"
)
// Plural returns a singular or plural of a word together with the total
func Plural(total int, singular, plural string) string {
if total == 1 {
return fmt.Sprintf("%d %s", total, singular)
}
return fmt.Sprintf("%d %s", total, plural)
}
// InArray tests if a string is within an array. It is not case sensitive.
func InArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
return true
}
}
return false
}
// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces
func Normalize(s string) string {
nlRe := regexp.MustCompile(`\r?\r`)
re := regexp.MustCompile(`\s+`)
s = nlRe.ReplaceAllString(s, " ")
s = re.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}

47
logger/logger.go Normal file
View File

@@ -0,0 +1,47 @@
package logger
import (
"encoding/json"
"fmt"
"os"
"github.com/axllent/mailpit/config"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
// verbose logging (debug)
log.SetLevel(logrus.DebugLevel)
} else if config.QuietLogging {
// show errors only
log.SetLevel(logrus.ErrorLevel)
} else if config.NoLogging {
// disable all logging (tests)
log.SetLevel(logrus.PanicLevel)
}
log.Out = os.Stdout
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006/01/02 15:04:05",
ForceColors: true,
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}

12
main.go
View File

@@ -3,7 +3,6 @@ package main
import (
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/cmd"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
@@ -16,19 +15,10 @@ func main() {
}
// running directly
if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) {
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
cmd.Execute()
} else {
// symlinked
sendmail.Run()
}
}
// Normalize returns a lowercase string stripped of the file extension (if exists).
// Used for detecting Windows commands which ignores letter casing and `.exe`.
// eg: "MaIlpIT.Exe" returns "mailpit"
func normalize(s string) string {
s = strings.ToLower(s)
return strings.TrimSuffix(s, filepath.Ext(s))
}

3830
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,34 +3,24 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "MINIFY=true node esbuild.config.mjs",
"watch": "WATCH=true node esbuild.config.mjs",
"package": "MINIFY=true node esbuild.config.mjs",
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
"build": "node esbuild.config.js",
"watch": "WATCH=true node esbuild.config.js",
"package": "MINIFY=true node esbuild.config.js"
},
"dependencies": {
"axios": "^1.2.1",
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"dayjs": "^1.11.10",
"ical.js": "^2.0.1",
"modern-screenshot": "^4.4.30",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"timezones-list": "^3.0.3",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
"tinycon": "^0.6.8",
"vue": "^3.2.13"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.23.0",
"esbuild": "^0.14.50",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^3.0.0"
"esbuild-sass-plugin": "^2.3.2"
}
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,133 +1,65 @@
// Package cmd is the sendmail cli
package cmd
/**
* Bare bones sendmail drop-in replacement borrowed from MailHog
*
* It uses a bit of a hack for flag parsing in order to be compatible
* with the cobra sendmail subcommand, as sendmail uses `-bc` which
* is not POSIX compatible.
*
* The -bs command-line switch causes sendmail to run a single SMTP session in the
* foreground over its standard input and output, and then exit. The SMTP session
* is exactly like a network SMTP session. Usually, one or more messages are
* submitted to sendmail for delivery.
*/
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"net/mail"
"net/smtp"
"os"
"os/user"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/reiver/go-telnet"
flag "github.com/spf13/pflag"
)
var (
// SMTPAddr address
SMTPAddr = "localhost:1025"
// FromAddr email address
FromAddr string
// UseB - used to set from `-bs`
UseB bool
// UseS - used to set from `-bs`
UseS bool
)
func init() {
// ensure only valid characters are used, ie: windows
re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`)
// Run the Mailpit sendmail replacement.
func Run() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
} else {
host = re.ReplaceAllString(host, "-")
}
username := "nobody"
user, err := user.Current()
if err == nil && user != nil && len(user.Username) > 0 {
username = re.ReplaceAllString(user.Username, "-")
username = user.Username
}
if FromAddr == "" {
FromAddr = username + "@" + host
}
}
fromAddr := username + "@" + host
smtpAddr := "localhost:1025"
var recip []string
// Run the Mailpit sendmail replacement.
func Run() {
var recipients []string
// defaults from env vars if provided
// defaults from envars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
FromAddr = os.Getenv("MP_SENDMAIL_FROM")
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
}
flag.StringVarP(&FromAddr, "from", "f", FromAddr, "SMTP sender")
flag.StringVarP(&SMTPAddr, "smtp-addr", "S", SMTPAddr, "SMTP server address")
flag.BoolVarP(&UseB, "long-b", "b", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolVarP(&UseS, "long-s", "s", false, "Handle SMTP commands on standard input (use as -bs)")
flag.BoolP("verbose", "v", false, "Ignored")
flag.BoolP("long-i", "i", false, "Ignored")
flag.BoolP("long-o", "o", false, "Ignored")
flag.BoolP("long-t", "t", false, "Ignored")
// set the default help
flag.Usage = func() {
fmt.Println(HelpTemplate(os.Args[0:1]))
}
var showHelp bool
// avoid 'pflag: help requested' error
flag.BoolVarP(&showHelp, "help", "h", false, "")
var verbose bool
// override defaults from cli flags
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
flag.BoolP("long-i", "i", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
flag.Parse()
// allow recipients to be passed as an argument
recipients = flag.Args()
// allow recipient to be passed as an argument
recip = flag.Args()
// if run via `mailpit sendmail ...` then remove `sendmail` from "recipients"
if len(recipients) > 0 && recipients[0] == "sendmail" {
recipients = recipients[1:]
if verbose {
fmt.Fprintln(os.Stderr, smtpAddr, fromAddr)
}
if showHelp {
flag.Usage()
os.Exit(0)
}
// ensure -bs is set
if UseB && !UseS || !UseB && UseS {
fmt.Printf("error: use -bs")
os.Exit(1)
}
// handles `sendmail -bs`
if UseB && UseS {
var caller telnet.Caller = telnet.StandardCaller
// telnet directly to SMTP
if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil {
fmt.Println(err)
os.Exit(1)
}
return
}
body, err := io.ReadAll(os.Stdin)
body, err := ioutil.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "error reading stdin")
os.Exit(11)
@@ -135,61 +67,19 @@ func Run() {
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing message body: %si\n", err)
fmt.Fprintln(os.Stderr, fmt.Sprintf("error parsing message body: %s", err))
os.Exit(11)
}
addresses := []string{}
if len(recipients) > 0 {
addresses = recipients
} else {
// get all recipients in To, Cc and Bcc
if to, err := msg.Header.AddressList("To"); err == nil {
for _, a := range to {
addresses = append(addresses, a.Address)
}
}
if cc, err := msg.Header.AddressList("Cc"); err == nil {
for _, a := range cc {
addresses = append(addresses, a.Address)
}
}
if bcc, err := msg.Header.AddressList("Bcc"); err == nil {
for _, a := range bcc {
addresses = append(addresses, a.Address)
}
}
if len(recip) == 0 {
// We only need to parse the message to get a recipient if none where
// provided on the command line.
recip = append(recip, msg.Header.Get("To"))
}
from, err := mail.ParseAddress(FromAddr)
if err != nil {
fmt.Fprintln(os.Stderr, "invalid from address")
os.Exit(11)
}
err = smtp.SendMail(SMTPAddr, nil, from.Address, addresses, body)
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
logger.Log().Fatal(err)
log.Fatal(err)
}
}
// HelpTemplate returns a string of the help
func HelpTemplate(args []string) string {
return fmt.Sprintf(`A sendmail command replacement for Mailpit (%s)
Usage: %s [flags] [recipients] < message
See: https://github.com/axllent/mailpit
Flags:
-S string SMTP server address (default "localhost:1025")
-f string Set the envelope sender address (default "%s")
-bs Handle SMTP commands on standard input
-t Ignored
-i Ignored
-o Ignored
-v Ignored
`, config.Version, strings.Join(args, " "), FromAddr)
}

279
server/api.go Normal file
View File

@@ -0,0 +1,279 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
type messagesResult struct {
Total int `json:"total"`
Unread int `json:"unread"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}
// Return a list of available mailboxes
func apiMailboxStats(w http.ResponseWriter, _ *http.Request) {
res := storage.StatsGet()
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// List messages
func apiListMessages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Search all messages
func apiSearchMessages(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
messages, err := storage.Search(search)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res messagesResult
res.Start = 0
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
res.Unread = stats.Unread
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
httpError(w, "Message not found")
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// Download/view an attachment
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// Download the full email source as plain text
func apiDownloadRaw(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// Delete all messages
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
err := storage.DeleteAllMessages()
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Delete all selected messages
func apiDeleteSelected(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Delete a single message
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
err := storage.DeleteOneMessage(id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark single message as unread
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
err := storage.MarkUnread(id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark all messages as read
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark selected message as read
func apiMarkSelectedRead(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark selected message as unread
func apiMarkSelectedUnread(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

View File

@@ -1,729 +0,0 @@
// Package apiv1 handles all the API responses
package apiv1
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/mail"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
)
// GetMessages returns a paginated list of messages as JSON
func GetMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/messages messages GetMessages
//
// # List messages
//
// Returns messages from the mailbox ordered from newest to oldest.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: start
// in: query
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: Limit results
// required: false
// type: integer
// default: 50
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
start, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
res.MessagesCount = stats.Total
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// Search returns the latest messages as JSON
func Search(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/search messages MessagesSummary
//
// # Search messages
//
// Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
// + name: start
// in: query
// description: Pagination offset
// required: false
// type: integer
// default: 0
// + name: limit
// in: query
// description: Limit results
// required: false
// type: integer
// default: 50
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
start, limit := getStartLimit(r)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = float64(results)
res.Unread = stats.Unread
res.Tags = stats.Tags
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// DeleteSearch will delete all messages matching a search
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/search messages DeleteSearch
//
// # Delete messages by search
//
// Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
// + name: tz
// in: query
// description: [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used specifically for `before:` & `after:` searches (eg: "Pacific/Auckland").
// required: false
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message Message
//
// # Get message summary
//
// Returns the summary of a message, marking the message as read.
//
// The ID can be set to `latest` to return the latest message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: Message
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(msg); err != nil {
httpError(w, err.Error())
}
}
// DownloadAttachment (method: GET) returns the attachment data
func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID} message Attachment
//
// # Get message attachment
//
// This will return the attachment part using the appropriate Content-Type.
//
// Produces:
// - application/*
// - image/*
// - text/*
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
fourOFour(w)
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(a.Content)
}
// GetHeaders (method: GET) returns the message headers as JSON
func GetHeaders(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/headers message Headers
//
// # Get message headers
//
// Returns the message headers as an array.
//
// The ID can be set to `latest` to return the latest message headers.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: MessageHeaders
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
reader := bytes.NewReader(data)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
httpError(w, err.Error())
}
}
// DownloadRaw (method: GET) returns the full email source as plain text
func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/raw message Raw
//
// # Get message source
//
// Returns the full email source as plain text.
//
// The ID can be set to `latest` to return the latest message source.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Message database ID or "latest"
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
data, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
_, _ = w.Write(data)
}
// DeleteMessages (method: DELETE) deletes all messages matching IDS.
func DeleteMessages(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/messages messages DeleteMessages
//
// # Delete messages
//
// Delete individual or all messages. If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil || len(data.IDs) == 0 {
if err := storage.DeleteAllMessages(); err != nil {
httpError(w, err.Error())
return
}
} else {
if err := storage.DeleteMessages(data.IDs); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "application/plain")
_, _ = w.Write([]byte("ok"))
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatus
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Read bool
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) == 0 {
if data.Read {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
} else {
err := storage.MarkAllUnread()
if err != nil {
httpError(w, err.Error())
return
}
}
} else {
if data.Read {
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
} else {
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// HTMLCheck returns a summary of the HTML client support
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
//
// # HTML check
//
// Returns the summary of the message HTML checker.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: HTMLCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
if msg.HTML == "" {
httpError(w, "message does not contain HTML")
return
}
checks, err := htmlcheck.RunTests(msg.HTML)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(checks); err != nil {
httpError(w, err.Error())
}
}
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
//
// # Link check
//
// Returns the summary of the message Link checker.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: LinkCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
f := r.URL.Query().Get("follow")
followRedirects := f == "true" || f == "1"
summary, err := linkcheck.RunTests(msg, followRedirects)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled)
func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck
//
// # SpamAssassin check
//
// Returns the SpamAssassin summary (if enabled) of the message.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: SpamAssassinResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
if id == "latest" {
var err error
id, err = storage.LatestID(r)
if err != nil {
w.WriteHeader(404)
fmt.Fprint(w, err.Error())
return
}
}
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
summary, err := spamassassin.Check(msg)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
}
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// httpJSONError returns a basic error message (400 response) in JSON format
func httpJSONError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
e := JSONErrorMessage{
Error: msg,
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(e); err != nil {
httpError(w, err.Error())
}
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
return start, limit
}
// GetOptions returns a blank response
func GetOptions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(""))
}

View File

@@ -1,31 +0,0 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/stats"
)
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/info application AppInformation
//
// # Get application information
//
// Returns basic runtime information, message totals and latest release version.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: InfoResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
httpError(w, err.Error())
}
}

View File

@@ -1,167 +0,0 @@
package apiv1
import (
"bytes"
"encoding/json"
"net/http"
"net/mail"
"strings"
"time"
"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/smtpd"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage
//
// # Release message
//
// Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
decoder := json.NewDecoder(r.Body)
data := releaseMessageRequestBody{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
blocked := []string{}
notAllowed := []string{}
for _, to := range data.To {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) {
notAllowed = append(notAllowed, to)
continue
}
if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) {
blocked = append(blocked, to)
continue
}
}
if len(notAllowed) > 0 {
addr := tools.Plural(len(notAllowed), "Address", "Addresses")
httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", "))
return
}
if len(blocked) > 0 {
addr := tools.Plural(len(blocked), "Address", "Addresses")
httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", "))
return
}
if len(data.To) == 0 {
httpError(w, "No valid addresses found")
return
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
from = senders[0].Address
}
msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP from
if config.SMTPRelayConfig.ReturnPath != "" {
if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" {
msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"})
if err != nil {
httpError(w, err.Error())
return
}
msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...)
}
from = config.SMTPRelayConfig.ReturnPath
}
// update message date
msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z))
if err != nil {
httpError(w, err.Error())
return
}
// generate unique ID
uid := shortuuid.New() + "@mailpit"
// update Message-Id with unique ID
msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">")
if err != nil {
httpError(w, err.Error())
return
}
if err := smtpd.Send(from, data.To, msg); err != nil {
logger.Log().Errorf("[smtp] error sending message: %s", err.Error())
httpError(w, "SMTP error: "+err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -1,275 +0,0 @@
package apiv1
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"strings"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/jhillyerd/enmime"
)
// swagger:parameters SendMessage
type sendMessageParams struct {
// in: body
Body *SendRequest
}
// SendRequest to send a message via HTTP
// swagger:model SendRequest
type SendRequest struct {
// "From" recipient
// required: true
From struct {
// Optional name
// example: John Doe
Name string
// Email address
// example: john@example.com
// required: true
Email string
}
// "To" recipients
To []struct {
// Optional name
// example: Jane Doe
Name string
// Email address
// example: jane@example.com
// required: true
Email string
}
// Cc recipients
Cc []struct {
// Optional name
// example: Manager
Name string
// Email address
// example: manager@example.com
// required: true
Email string
}
// Bcc recipients email addresses only
// example: ["jack@example.com"]
Bcc []string
// Optional Reply-To recipients
ReplyTo []struct {
// Optional name
// example: Secretary
Name string
// Email address
// example: secretary@example.com
// required: true
Email string
}
// Subject
// example: Mailpit message via the HTTP API
Subject string
// Message body (text)
// example: This is the text body
Text string
// Message body (HTML)
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
HTML string
// Attachments
Attachments []struct {
// Base64-encoded string of the file content
// required: true
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
Content string
// Filename
// required: true
// example: AttachedFile.txt
Filename string
}
// Mailpit tags
// example: ["Tag 1","Tag 2"]
Tags []string
// Optional headers in {"key":"value"} format
// example: {"X-IP":"1.2.3.4"}
Headers map[string]string
}
// SendMessageConfirmation struct
type SendMessageConfirmation struct {
// Database ID
// example: iAfZVVe2UQFNSG5BAjgYwa
ID string
}
// JSONErrorMessage struct
type JSONErrorMessage struct {
// Error message
// example: invalid format
Error string
}
// SendMessageHandler handles HTTP requests to send a new message
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/send message SendMessage
//
// # Send a message
//
// Send a message via the HTTP API.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: sendMessageResponse
// default: jsonErrorResponse
decoder := json.NewDecoder(r.Body)
data := SendRequest{}
if err := decoder.Decode(&data); err != nil {
httpJSONError(w, err.Error())
return
}
id, err := data.Send(r.RemoteAddr)
if err != nil {
httpJSONError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
httpError(w, err.Error())
}
}
// Send will validate the message structure and attempt to send to Mailpit.
// It returns a sending summary or an error.
func (d SendRequest) Send(remoteAddr string) (string, error) {
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
}
ipAddr := &net.IPAddr{IP: net.ParseIP(ip)}
addresses := []string{}
msg := enmime.Builder().
From(d.From.Name, d.From.Email).
Subject(d.Subject).
Text([]byte(d.Text))
if d.HTML != "" {
msg = msg.HTML([]byte(d.HTML))
}
if len(d.To) > 0 {
for _, a := range d.To {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.To(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid To address: %s", a.Email)
}
}
}
if len(d.Cc) > 0 {
for _, a := range d.Cc {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.CC(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid Cc address: %s", a.Email)
}
}
}
if len(d.Bcc) > 0 {
for _, e := range d.Bcc {
if _, err := mail.ParseAddress(e); err == nil {
msg = msg.BCC("", e)
addresses = append(addresses, e)
} else {
return "", fmt.Errorf("invalid Bcc address: %s", e)
}
}
}
if len(d.ReplyTo) > 0 {
for _, a := range d.ReplyTo {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.ReplyTo(a.Name, a.Email)
} else {
return "", fmt.Errorf("invalid Reply-To address: %s", a.Email)
}
}
}
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
if len(d.Tags) > 0 {
msg = msg.Header("X-Tags", strings.Join(d.Tags, ", "))
restrictedHeaders = append(restrictedHeaders, "X-Tags")
}
if len(d.Headers) > 0 {
for k, v := range d.Headers {
// check header isn't in "restricted" headers
if tools.InArray(k, restrictedHeaders) {
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
}
msg = msg.Header(k, v)
}
}
if len(d.Attachments) > 0 {
for _, a := range d.Attachments {
// workaround: split string because JS readAsDataURL() returns the base64 string
// with the mime type prefix eg: data:image/png;base64,<base64String>
parts := strings.Split(a.Content, ",")
content := parts[len(parts)-1]
b, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
}
mimeType := http.DetectContentType(b)
msg = msg.AddAttachment(b, mimeType, a.Filename)
}
}
part, err := msg.Build()
if err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
var buff bytes.Buffer
if err := part.Encode(io.Writer(&buff)); err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
}

View File

@@ -1,56 +0,0 @@
package apiv1
import (
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
)
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total float64 `json:"total"`
// Total number of unread messages in mailbox
Unread float64 `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count float64 `json:"count"`
// Total number of messages matching current query
MessagesCount float64 `json:"messages_count"`
// Pagination offset
Start int `json:"start"`
// All current tags
Tags []string `json:"tags"`
// Messages summary
// in: body
Messages []storage.MessageSummary `json:"messages"`
}
// The following structs & aliases are provided for easy import
// and understanding of the JSON structure.
// MessageSummary - summary of a single message
type MessageSummary = storage.MessageSummary
// Message data
type Message = storage.Message
// Attachment summary
type Attachment = storage.Attachment
// HTMLCheckResponse summary
type HTMLCheckResponse = htmlcheck.Response
// LinkCheckResponse summary
type LinkCheckResponse = linkcheck.Response
// SpamAssassinResponse summary
type SpamAssassinResponse = spamassassin.Result

View File

@@ -1,19 +0,0 @@
consumes:
- application/json
info:
description: |-
OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit).
title: Mailpit API
contact:
name: GitHub
url: https://github.com/axllent/mailpit
license:
name: MIT license
url: https://github.com/axllent/mailpit/blob/develop/LICENSE
version: "v1"
paths: {}
produces:
- application/json
schemes:
- http
swagger: "2.0"

View File

@@ -1,215 +0,0 @@
package apiv1
import "github.com/axllent/mailpit/internal/stats"
// These structs are for the purpose of defining swagger HTTP parameters & responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
//
// in: body
Body stats.AppInformation
}
// Web UI configuration
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
//
// in: body
Body webUIConfiguration
}
// Message summary
// swagger:response MessagesSummaryResponse
type messagesSummaryResponse struct {
// The message summary
// in: body
Body MessagesSummary
}
// Message headers
// swagger:model MessageHeaders
type messageHeaders map[string][]string
// swagger:parameters DeleteMessages
type deleteMessagesParams struct {
// in: body
Body *deleteMessagesRequestBody
}
// Delete request
// swagger:model DeleteRequest
type deleteMessagesRequestBody struct {
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters SetReadStatus
type setReadStatusParams struct {
// in: body
Body *setReadStatusRequestBody
}
// Set read status request
// swagger:model setReadStatusRequestBody
type setReadStatusRequestBody struct {
// Read status
//
// required: false
// default: false
// example: true
Read bool
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters SetTags
type setTagsParams struct {
// in: body
Body *setTagsRequestBody
}
// Set tags request
// swagger:model setTagsRequestBody
type setTagsRequestBody struct {
// Array of tag names to set
//
// required: true
// example: ["Tag 1", "Tag 2"]
Tags []string
// Array of message database IDs
//
// required: true
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string
}
// swagger:parameters RenameTag
type renameTagParams struct {
// in: body
Body *renameTagRequestBody
}
// Rename tag request
// swagger:model renameTagRequestBody
type renameTagRequestBody struct {
// New name
//
// required: true
// example: New name
Name string
}
// swagger:parameters ReleaseMessage
type releaseMessageParams struct {
// Message database ID
//
// in: path
// description: Message database ID
// required: true
ID string
// in: body
Body *releaseMessageRequestBody
}
// Release request
// swagger:model releaseMessageRequestBody
type releaseMessageRequestBody struct {
// Array of email addresses to relay the message to
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string
}
// swagger:parameters HTMLCheck
type htmlCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
}
// swagger:parameters LinkCheck
type linkCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
// Follow redirects
//
// in: query
// description: Follow redirects
// required: false
// default: false
Follow string `json:"follow"`
}
// swagger:parameters SpamAssassinCheck
type spamAssassinCheckParams struct {
// Message database ID or "latest"
//
// in: path
// description: Message database ID or "latest"
// required: true
ID string
}
// Binary data response inherits the attachment's content type.
// swagger:response BinaryResponse
type binaryResponse string
// Plain text response
// swagger:response TextResponse
type textResponse string
// HTML response
// swagger:response HTMLResponse
type htmlResponse string
// HTTP error response will return with a >= 400 response code
// swagger:response ErrorResponse
// example: invalid request
type errorResponse string
// Plain text "ok" response
// swagger:response OKResponse
type okResponse string
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string
// Confirmation message for HTTP send API
// swagger:response sendMessageResponse
type sendMessageResponse struct {
// Response for sending messages via the HTTP API
//
// in: body
Body SendMessageConfirmation
}
// JSON error response
// swagger:response jsonErrorResponse
type jsonErrorResponse struct {
// A JSON-encoded error response
//
// in: body
Body JSONErrorMessage
}

View File

@@ -1,171 +0,0 @@
package apiv1
import (
"encoding/json"
"net/http"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
// GetAllTags (method: GET) will get all tags currently in use
func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags GetAllTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: ArrayResponse
// default: ErrorResponse
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
func SetMessageTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
decoder := json.NewDecoder(r.Body)
var data struct {
Tags []string
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
ids := data.IDs
if len(ids) > 0 {
for _, id := range ids {
if _, err := storage.SetMessageTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// RenameTag (method: PUT) used to rename a tag
func RenameTag(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags/{tag} tags RenameTag
//
// # Rename a tag
//
// Renames a tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to rename
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
decoder := json.NewDecoder(r.Body)
var data struct {
Name string
}
err := decoder.Decode(&data)
if err != nil {
httpError(w, err.Error())
return
}
if err := storage.RenameTag(tag, data.Name); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// DeleteTag (method: DELETE) used to delete a tag
func DeleteTag(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/tags/{tag} tags DeleteTag
//
// # Delete a tag
//
// Deletes a tag. This will not delete any messages with this tag.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: tag
// in: path
// description: The url-encoded tag name to delete
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
if err := storage.DeleteTag(tag); err != nil {
httpError(w, err.Error())
return
}
websockets.Broadcast("prune", nil)
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}

View File

@@ -1,78 +0,0 @@
package apiv1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/axllent/mailpit/config"
)
// Response includes global web UI settings
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
Enabled bool
// The configured SMTP server address
SMTPServer string
// Enforced Return-Path (if set) for relay bounces
ReturnPath string
// Only allow relaying to these recipients (regex)
AllowedRecipients string
// Block relaying to these recipients (regex)
BlockedRecipients string
// DEPRECATED 2024/03/12
// swagger:ignore
RecipientAllowlist string
}
// Whether SpamAssassin is enabled
SpamAssassin bool
// Whether messages with duplicate IDs are ignored
DuplicatesIgnored bool
}
// WebUIConfig returns configuration settings for the web UI.
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/webui application WebUIConfiguration
//
// # Get web UI configuration
//
// Returns configuration settings for the web UI.
// Intended for web UI only!
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: WebUIConfigurationResponse
// default: ErrorResponse
conf := webUIConfiguration{}
conf.Label = config.Label
conf.MessageRelay.Enabled = config.ReleaseEnabled
if config.ReleaseEnabled {
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath
conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients
conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients
// DEPRECATED 2024/03/12
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients
}
conf.SpamAssassin = config.EnableSpamAssassin != ""
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(conf); err != nil {
httpError(w, err.Error())
}
}

View File

@@ -1,8 +0,0 @@
package handlers
import "net/http"
// HealthzHandler is a liveness probe
func HealthzHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,19 +0,0 @@
package handlers
import (
"net/http"
"sync/atomic"
"github.com/axllent/mailpit/internal/storage"
)
// ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic
func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
if isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}

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