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
99 changed files with 2887 additions and 16334 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 -}}

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

@@ -10,6 +10,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -22,23 +26,12 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_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@v4
uses: docker/build-push-action@v3
with:
context: .
# platforms: linux/386,linux/amd64,linux/arm,linux/arm64
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 }}
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@v8.0.0
with:
days-before-issue-stale: 21
days-before-issue-close: 7
exempt-issue-labels: "enhancement,bug,javascript,docker"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 21 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,30 +10,29 @@ jobs:
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@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
# build the assets
- 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.39
- 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

@@ -12,7 +12,7 @@ jobs:
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v4
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
@@ -24,13 +24,13 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./storage ./server -v
- run: go test ./storage -v
- run: go test ./storage -bench=.
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 16
cache: 'npm'
- run: npm install
- run: npm run package

2
.gitignore vendored
View File

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

View File

@@ -2,561 +2,7 @@
Notable changes to Mailpit will be documented in this file.
## [v1.8.2]
### Build
- Update wangyoucao577/go-release-action[@v1](https://github.com/v1).39
### Feature
- Link check to test message links
- Workaround for non-RFC-compliant message headers containing <CR><CR><LF>
### Libs
- Update Go libs
### UI
- Set hostname in page meta title to identify Mailpit instance
## [v1.8.1]
### Docs
- Add pagination to swagger search documentation
### Fix
- Check/set message Reply-To using SMTP FROM
- Exclude "sendmail" from recipients list when using `mailpit sendmail <options>`
- Exclude <script type="application/json"> from HTML check tests
### Libs
- Update node modules
- Update Go modules
## [v1.8.0]
### Docs
- Update brew installation instructions
### Feature
- HTML check to test & score mail client compatibility with HTML emails
### Fix
- Add basePath to swagger.json if webroot is specified
### Libs
- Update node modules
- Update Go modules
### Swagger
- Update swagger docs
### UI
- Add flag to block all access to remote CSS and fonts (CSP)
- Remove `<base />` tag if set in HTML preview
- Pagination support for search, all results
## [v1.7.1]
### Libs
- Update Go modules
- Update node modules
### UI
- Wrap HTML source lines
- Dark mode color adjustments
- Update dark mode loading background color
## [v1.7.0]
### API
- Ignore SMTP relay error when one of multiple recipients doesn't exist
- Set raw message Content-Type to UTF-8
### Build
- Define Vue build options in esbuild
### Libs
- Update node modules
- Update Go modules
### UI
- Theme toggler - auto, light and dark themes
## [v1.6.22]
### Feature
- Clearer SMTP error messages
### Libs
- Update Go modules
- Upgrade node modules
## [v1.6.21]
### UI
- More accurate clickable hyperlink logic in plain text messages
## [v1.6.20]
### Feature
- Convert links into clickable hyperlinks in plain text message content
### Libs
- Update node modules
## [v1.6.19]
### Fix
- Only display sendmail help when sendmail subcommand is invoked
## [v1.6.18]
### API
- Sort tags before saving
### UI
- Add option to enable tag colors based on tag name hash
- Display message tags below subject in message overview
## [v1.6.17]
### Fix
- Add single dash arguments support to sendmail command ([#123](https://github.com/axllent/mailpit/issues/123))
## [v1.6.16]
### Bugfix
- Fix sendmail/startup panic
## [v1.6.15]
### Feature
- Add `sendmail -bs` functionality
## [v1.6.14]
### Feature
- Add ability to delete or mark search results read
- Set tags via X-Tags message header
### Libs
- Update node modules
## [v1.6.13]
### Feature
- Add SMTP LOGIN authentication method for message relay
## [v1.6.12]
### Feature
- Add Message-Id to MessageSummary ([#116](https://github.com/axllent/mailpit/issues/116))
### Swagger
- Update swagger field descriptions, add MessageID
## [v1.6.11]
### Libs
- Update node modules
- Update Go modules
### UI
- Check for secure context instead of HTTPS ([#114](https://github.com/axllent/mailpit/issues/114))
## [v1.6.10]
### Libs
- Update node modules
- Update Go modules
### UI
- Remove "Noto Color Emoji" from default bootstrap font list
## [v1.6.9]
### API
- Return blank 200 response for OPTIONS requests (CORS)
### Bugfix
- Correctly escape JS cid regex
### Libs
- Update node modules
- Update Go modules
## [v1.6.8]
### Bugfix
- Fix Date display when message doesn't contain a Date header
### Feature
- Add allowlist to filter recipients before relaying messages ([#109](https://github.com/axllent/mailpit/issues/109))
- Add `-S` short flag for sendmail `--smtp-addr`
## [v1.6.7]
### Bugfix
- Fix auto-deletion cron
## [v1.6.6]
### API
- Set Access-Control-Allow-Headers when --api-cors is set
- Include correct start value in search reponse
### Feature
- Option to ignore duplicate Message-IDs
### Libs
- Update node modules
- Update Go modules
### Swagger
- Update swagger field descriptions
### UI
- Style Undisclosed recipients in message view
## [v1.6.5]
### Feature
- Add Access-Control-Allow-Methods methods when CORS origin is set
## [v1.6.4]
### Bugfix
- Fix UI images not displaying when multiple cid names overlap
## [v1.6.3]
### Feature
- Display clickable toast notifications for new messages
## [v1.6.2]
### Bugfix
- If set use return-path address as SMTP from address
## [v1.6.1]
### Bugfix
- Add API release route again (bad merge)
## [v1.6.0]
### API
- Enable cross-origin resource sharing (CORS) configuration
- Message relay / release
- Include Return-Path in message summary data
### Feature
- Inject/update Bcc header for missing addresses when SMTP recipients do not match messsage headers
### Libs
- Update Go modules
- Update node modules
### UI
- Display Return-Path if different to the From address
- Message release functionality
## [v1.5.5]
### Docker
- Add Docker image tag for major/minor version
### Feature
- Update listen regex to allow IPv6 addresses ([#85](https://github.com/axllent/mailpit/issues/85))
## [v1.5.4]
### Feature
- Mobile and tablet HTML preview toggle in desktop mode
## [v1.5.3]
### Bugfix
- Enable SMTP auth flags to be set via env
## [v1.5.2]
### API
- Include Reply-To in message summary (including Web UI)
### UI
- Tab to view formatted message headers
## [v1.5.1]
### Feature
- Add 'o', 'b' & 's' ignored flags for sendmail
### Libs
- Update Go modules
- Update node modules
## [v1.5.0]
### API
- Return received datetime when message does not contain a date header
### Bugfix
- Fix JavaScript error when adding the first tag manually
### Feature
- OpenAPI / Swagger schema
- Download raw message, HTML/text body parts or attachments via single button
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.4.0]
### API
- Return received datetime when message does not contain a date header
### Feature
- Rename SSL to TLS, add deprecation warnings to flags & ENV variables referring to SSL
- Options to support auth without STARTTLS, and accept any login
- Option to use message dates as received dates (new messages only)
## [v1.3.11]
### Docker
- Expose default ports (1025/tcp 8025/tcp)
### Feature
- Expand custom webroot path to include a-z A-Z 0-9 _ . - and /
## [v1.3.10]
### Bugfix
- Fix search with existing emails
### Libs
- Update node modules
## [v1.3.9]
### Feature
- Add Cc and Bcc search filters
### Libs
- Update node modules
- Update Go modules
### Pull Requests
- Merge pull request [#44](https://github.com/axllent/mailpit/issues/44) from axllent/dependabot/github_actions/wangyoucao577/go-release-action-1.36
- Merge pull request [#43](https://github.com/axllent/mailpit/issues/43) from axllent/dependabot/github_actions/docker/build-push-action-4
- Merge pull request [#55](https://github.com/axllent/mailpit/issues/55) from axllent/dependabot/go_modules/golang.org/x/image-0.5.0
- Merge pull request [#42](https://github.com/axllent/mailpit/issues/42) from shizunge/dependabot
## [v1.3.8]
### Bugfix
- Restore notification icon
### UI
- Compress SVG icons
## [v1.3.7]
### Feature
- Add Kubernetes API health (livez/readyz) endpoints
### Libs
- Upgrade to esbuild 0.17.5
## [v1.3.6]
### Bugfix
- Correctly index missing 'From' header in database
### Libs
- Update node modules
- Update go modules
## [v1.3.5]
### Bugfix
- Include HTML link text in search data
## [v1.3.4]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.3]
### Bugfix
- Allow tags to be set from MP_TAG environment
## [v1.3.2]
### Build
- Temporarily disable arm (32) Docker build
## [v1.3.1]
### Bugfix
- Append trailing slash to custom webroot for UI & API
### Libs
- Upgrade esbuild & axios
### UI
- Rename "results" to "result" when singular message returned
## [v1.3.0]
### Build
- Remove duplicate bootstrap CSS
### Libs
- Update go modules
- Update node modules
## [v1.2.9]
### Bugfix
- Delay 200ms to set `target="_blank"` for all rendered email links
## [v1.2.8]
### Bugfix
- Return empty arrays rather than null for message To, CC, BCC, Inlines & Attachments
### Feature
- Message tags and auto-tagging
## [v1.2.7]
### Feature
- Allow custom webroot
## [v1.2.6]
### API
- Provide structs of API v1 responses for use in client code
### Libs
- Update go modules
- Update node modules
## [1.2.5]
### UI
- Broadcast "delete all" action to reload all connected clients
- Load first page if paginated list returns 0 results
- Theme changes
- Bump build action to use node 18
## [1.2.4]
### Bugfix
- Fix mail download link
## [1.2.3]
### API
- Add limit and start parameters to search
### UI
- Prevent double message index request on websocket connect
## [1.2.2]
### API
- Add API endpoint to return message headers
### Libs
- Update go modules
### Testing
- Add API test for raw & message headers
## [1.2.1]
### UI
- Update frontend modules
- Add about app modal with version update notification
## [1.2.0]
### Feature
- Add REST API
### Testing
- Add API tests
### UI
- Changes to use new data API
- Hide delete all / mark all read in message view
## [1.1.7]
### Fix
- Normalize running binary name detection (Windows)
## [1.1.6]
## 1.1.6
### Fix
- Workaround for Safari source matching bug blocking event listener
@@ -565,7 +11,7 @@ Notable changes to Mailpit will be documented in this file.
- Add documentation link (wiki)
## [1.1.5]
## 1.1.5
### Build
- Switch to esbuild-sass-plugin
@@ -574,7 +20,7 @@ Notable changes to Mailpit will be documented in this file.
- Support for inline images using filenames instead of cid
## [1.1.4]
## 1.1.4
### Feature
- Add --quiet flag to display only errors
@@ -588,32 +34,32 @@ Notable changes to Mailpit will be documented in this file.
- Remove left & right borders (message list)
## [1.1.3]
## 1.1.3
### Fix
- Update message download link
## [1.1.2]
## 1.1.2
### UI
- Allow reverse proxy subdirectories
## [1.1.1]
## 1.1.1
### UI
- Attachment icons and image thumbnails
## [1.1.0]
## 1.1.0
### UI
- HTML source & highlighting
- Add previous/next message links
## [1.0.0]
## 1.0.0
### Feature
- Multiple message selection for group actions using shift/ctrl click
@@ -629,7 +75,7 @@ Notable changes to Mailpit will be documented in this file.
- Update frontend modules & esbuild
## [1.0.0-beta1]
## 1.0.0-beta1
### BREAKING CHANGE
@@ -642,7 +88,7 @@ This release includes a major backend storage change (SQLite) that will render a
- Resize preview iframe on load
## [0.1.5]
## 0.1.5
### Feature
- Improved message search - any order & phrase quoting
@@ -652,7 +98,7 @@ This release includes a major backend storage change (SQLite) that will render a
- Resize iframes with viewport resize
## [0.1.4]
## 0.1.4
### Feature
- Email compression in storage
@@ -665,7 +111,7 @@ This release includes a major backend storage change (SQLite) that will render a
- Mobile compatibility improvements & functionality
## [0.1.3]
## 0.1.3
### Feature
- Mark all messages as read
@@ -680,7 +126,7 @@ This release includes a major backend storage change (SQLite) that will render a
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
## [0.1.2]
## 0.1.2
### Feature
- Optional browser notifications (HTTPS only)
@@ -691,19 +137,19 @@ This release includes a major backend storage change (SQLite) that will render a
- Use strconv.Atoi() for safe string to int conversions
## [0.1.1]
## 0.1.1
### Bugfix
- Fix env variable for MP_UI_SSL_KEY
## [0.1.0]
## 0.1.0
### Feature
- SMTP STARTTLS & SMTP authentication support
## [0.0.9]
## 0.0.9
### Bugfix
- Include read status in search results
@@ -715,7 +161,7 @@ This release includes a major backend storage change (SQLite) that will render a
- Memory & physical database tests
## [0.0.8]
## 0.0.8
### Bugfix
- Fix total/unread count after failed message inserts
@@ -724,25 +170,25 @@ This release includes a major backend storage change (SQLite) that will render a
- Add project links to help in CLI
## [0.0.7]
## 0.0.7
### Bugfix
- Command flag should be `--auth-file`
## [0.0.6]
## 0.0.6
### Bugfix
- Disable CGO when building multi-arch binaries
## [0.0.5]
## 0.0.5
### Feature
- Basic authentication support
## [0.0.4]
## 0.0.4
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
@@ -759,13 +205,13 @@ This release includes a major backend storage change (SQLite) that will render a
- cater for messages without From email address
## [0.0.3]
## 0.0.3
### Bugfix
- Update to clover-v2.0.0-alpha.2 to fix sorting
## [0.0.2]
## 0.0.2
### Feature
- Unread statistics

View File

@@ -8,7 +8,8 @@ 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
@@ -16,6 +17,4 @@ COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
EXPOSE 1025/tcp 8025/tcp
ENTRYPOINT ["/mailpit"]

View File

@@ -6,86 +6,68 @@
![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)
Mailpit is a small, fast, low memory, zero-dependency, multi-platform email testing tool & API for developers.
Mailpit is a multi-platform email testing tool for developers.
It acts as an SMTP server, provides a modern web interface to view & test captured emails, and contains an API for automated integration testing.
It acts as both an SMTP server, and provides a web interface to view all captured emails.
Mailpit was originally **inspired** by MailHog which is now [no longer maintained](https://github.com/mailhog/MailHog/issues/442#issuecomment-1493415258) and hasn't seen active development for a few years now.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/docs/screenshot.png)
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
## Features
- 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, headers, raw source and MIME attachments including image thumbnails)
- HTML check to test & score mail client compatibility with HTML emails
- Link check to test message links (HTML & text) & linked images
- Light & dark web UI theme with auto-detect
- Mobile and tablet HTML preview toggle in desktop mode
- 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))
- Message tagging ([see wiki](https://github.com/axllent/mailpit/wiki/Tagging))
- Real-time web UI updates using web sockets for new mail
- Optional browser notifications for new mail (when accessed via either HTTPS or `localhost` only)
- 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, easily handling tens of thousands of emails
- SMTP relaying / message release - relay messages via a different SMTP server including an optional allowlist of accepted recipients ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-relay))
- Optional SMTP with STARTTLS & SMTP authentication, including an "accept anything" mode ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- 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))
- A simple REST API ([see docs](docs/apiv1/README.md))
- 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 Brew (Mac)
Install Mailpit with `brew 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)
```
### Download static binary (Windows, Linux and Mac)
Static binaries can always be found on the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` binary can extracted and copied to your `$PATH`, or simply run as `./mailpit`.
### Docker
See [Docker instructions](https://github.com/axllent/mailpit/wiki/Docker-images) for 386, amd64 & arm64 images.
### Compile from source
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.
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
## Usage
Run `mailpit -h` to see options. More information can be seen in [the docs](https://github.com/axllent/mailpit/wiki/Runtime-options).
### Testing Mailpit
Please refer to [the documentation](https://github.com/axllent/mailpit/wiki/Testing-Mailpit) of 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 (by 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 a SMTP server is `sendmail`, used by many applications including PHP. Mailpit can also act as substitute for sendmail. For instructions of how to set this up, please refer to the [sendmail documentation](https://github.com/axllent/mailpit/wiki/Configuring-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,17 +1,15 @@
// Package cmd is the main application
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/server/smtpd"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/spf13/cobra"
)
@@ -74,74 +72,6 @@ func init() {
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").Hidden = true
// load and warn deprecated ENV vars
initDeprecatedConfigFromEnv()
// load ENV vars
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API")
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI 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(&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.SMTPAuthAllowInsecure, "smtp-auth-allow-insecure", config.SMTPAuthAllowInsecure, "Enable insecure PLAIN & LOGIN authentication")
rootCmd.Flags().BoolVar(&config.SMTPStrictRFCHeaders, "smtp-strict-rfc-headers", config.SMTPStrictRFCHeaders, "Return SMTP error if message headers contain <CR><CR><LF>")
rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages")
rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)")
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVarP(&logger.QuietLogging, "quiet", "q", logger.QuietLogging, "Quiet logging (errors only)")
rootCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging")
// deprecated flags 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ssl-cert", config.UITLSCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ssl-key", config.UITLSKey, "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-tls-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-tls-key"
// deprecated flags 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"
// 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"
}
// Load settings from environment
func initConfigFromEnv() {
// defaults from envars if provided
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.DataFile = os.Getenv("MP_DATA_FILE")
@@ -155,123 +85,73 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
// UI
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
}
if len(os.Getenv("MP_UI_TLS_CERT")) > 0 {
config.UITLSCert = os.Getenv("MP_UI_TLS_CERT")
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
}
if len(os.Getenv("MP_UI_TLS_KEY")) > 0 {
config.UITLSKey = os.Getenv("MP_UI_TLS_KEY")
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
}
// SMTP
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
}
if len(os.Getenv("MP_SMTP_TLS_CERT")) > 0 {
config.SMTPTLSCert = os.Getenv("MP_SMTP_TLS_CERT")
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
config.SMTPSSLCert = os.Getenv("MP_SMTP_SSL_CERT")
}
if len(os.Getenv("MP_SMTP_TLS_KEY")) > 0 {
config.SMTPTLSKey = os.Getenv("MP_SMTP_TLS_KEY")
}
if getEnabledFromEnv("MP_SMTP_AUTH_ACCEPT_ANY") {
config.SMTPAuthAcceptAny = true
}
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true
}
if getEnabledFromEnv("MP_STRICT_RFC_HEADERS") {
config.SMTPStrictRFCHeaders = true
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
}
// Relay server config
if len(os.Getenv("MP_SMTP_RELAY_CONFIG")) > 0 {
config.SMTPRelayConfigFile = os.Getenv("MP_SMTP_RELAY_CONFIG")
}
if getEnabledFromEnv("MP_SMTP_RELAY_ALL") {
config.SMTPRelayAllIncoming = true
}
// Misc options
if len(os.Getenv("MP_WEBROOT")) > 0 {
config.Webroot = os.Getenv("MP_WEBROOT")
}
if len(os.Getenv("MP_API_CORS")) > 0 {
server.AccessControlAllowOrigin = os.Getenv("MP_API_CORS")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
config.IgnoreDuplicateIDs = true
}
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
config.DisableHTMLCheck = true
}
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if getEnabledFromEnv("MP_QUIET") {
logger.QuietLogging = true
}
if getEnabledFromEnv("MP_VERBOSE") {
logger.VerboseLogging = true
}
}
// load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() {
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
fmt.Println("ENV MP_AUTH_FILE has been deprecated, use MP_UI_AUTH_FILE")
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_SSL_CERT")
config.UISSLCert = os.Getenv("MP_SSL_CERT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_TLS_KEY")
config.UISSLKey = os.Getenv("MP_SSL_KEY")
}
// deprecated 2022/08/28
if len(os.Getenv("MP_DATA_DIR")) > 0 {
fmt.Println("ENV MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
fmt.Println("MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
config.DataFile = os.Getenv("MP_DATA_DIR")
}
// deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
fmt.Println("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
config.UITLSCert = os.Getenv("MP_UI_SSL_CERT")
}
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
fmt.Println("ENV MP_UI_SSL_KEY has been deprecated, use MP_UI_TLS_KEY")
config.UITLSKey = os.Getenv("MP_UI_SSL_KEY")
}
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
fmt.Println("ENV MP_SMTP_CERT has been deprecated, use MP_SMTP_TLS_CERT")
config.SMTPTLSCert = os.Getenv("MP_SMTP_SSL_CERT")
}
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
fmt.Println("ENV MP_SMTP_KEY has been deprecated, use MP_SMTP_TLS_KEY")
config.SMTPTLSKey = os.Getenv("MP_SMTP_SMTP_KEY")
}
}
// 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"
}
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")
return false
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/utils/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,28 +1,21 @@
// Package config handles the application configuration
package config
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
"gopkg.in/yaml.v3"
)
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"
// DataFile for mail (optional)
DataFile string
@@ -30,127 +23,47 @@ var (
// 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
// UISSLCert file
UISSLCert string
// UISSLKey file
UISSLKey string
// UIAuthFile for basic authentication
UIAuthFile string
// UIAuth used for authentication
// UIAuth used for euthentication
UIAuth *htpasswd.File
// Webroot to define the base path for the UI and API
Webroot = "/"
// SMTPSSLCert file
SMTPSSLCert string
// SMTPTLSCert file
SMTPTLSCert string
// SMTPTLSKey file
SMTPTLSKey string
// SMTPSSLKey file
SMTPSSLKey string
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuthConfig used for authentication auto-generated from SMTPAuthFile
SMTPAuthConfig *htpasswd.File
// SMTPAuthAllowInsecure allows PLAIN & LOGIN unencrypted authentication
SMTPAuthAllowInsecure bool
// SMTPAuthAcceptAny accepts any username/password including none
SMTPAuthAcceptAny bool
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
DisableHTMLCheck = false
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
BlockRemoteCSSAndFonts = false
// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_]){3,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
// SMTPRelayConfig to parse a yaml file and store config of relay SMTP server
SMTPRelayConfig smtpRelayConfigStruct
// 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
// ReleaseEnabled is whether message releases are enabled, requires a valid SMTPRelayConfigFile
ReleaseEnabled = false
// SMTPRelayAllIncoming is whether to relay all incoming messages via pre-configured SMTP server.
// Use with extreme caution!
SMTPRelayAllIncoming = false
// ContentSecurityPolicy for HTTP server - set via VerifyConfig()
ContentSecurityPolicy string
// 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"
// SMTPAuth used for euthentication
SMTPAuth *htpasswd.File
)
// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
Match string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
type smtpRelayConfigStruct struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
STARTTLS bool `yaml:"starttls"`
AllowInsecure bool `yaml:"allow-insecure"`
Auth string `yaml:"auth"` // none, plain, login, cram-md5
Username string `yaml:"username"` // plain & cram-md5
Password string `yaml:"password"` // plain
Secret string `yaml:"secret"` // cram-md5
ReturnPath string `yaml:"return-path"` // allow overriding the bounce address
RecipientAllowlist string `yaml:"recipient-allowlist"` // regex, if set needs to match for mails to be relayed
RecipientAllowlistRegexp *regexp.Regexp
}
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
cssFontRestriction := "*"
if BlockRemoteCSSAndFonts {
cssFontRestriction = "'self'"
}
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
cssFontRestriction, cssFontRestriction,
)
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
}
re := regexp.MustCompile(`^((\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(\[([\da-fA-F:])+\])):\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>")
}
@@ -170,31 +83,31 @@ func VerifyConfig() error {
UIAuth = a
}
if UITLSCert != "" && UITLSKey == "" || UITLSCert == "" && UITLSKey != "" {
return errors.New("You must provide both a UI TLS certificate and a key")
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
return errors.New("you must provide both a UI SSL certificate and a key")
}
if UITLSCert != "" {
if !isFile(UITLSCert) {
return fmt.Errorf("TLS certificate not found: %s", UITLSCert)
if UISSLCert != "" {
if !isFile(UISSLCert) {
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
}
if !isFile(UITLSKey) {
return fmt.Errorf("TLS key not found: %s", UITLSKey)
if !isFile(UISSLKey) {
return fmt.Errorf("SSL key not found: %s", UISSLKey)
}
}
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
return errors.New("You must provide both an SMTP 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 SMTPTLSCert != "" {
if !isFile(SMTPTLSCert) {
return fmt.Errorf("SMTP TLS certificate not found: %s", SMTPTLSCert)
if SMTPSSLCert != "" {
if !isFile(SMTPSSLCert) {
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
}
if !isFile(SMTPTLSKey) {
return fmt.Errorf("SMTP TLS key not found: %s", SMTPTLSKey)
if !isFile(SMTPSSLKey) {
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
}
}
@@ -203,136 +116,15 @@ func VerifyConfig() error {
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
if SMTPAuthAcceptAny {
return errors.New("SMTP authentication can either use --smtp-auth-file or --smtp-auth-accept-any")
if SMTPSSLCert == "" {
return errors.New("SMTP authentication requires SMTP encryption")
}
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
SMTPAuthConfig = a
}
if SMTPTLSCert == "" && (SMTPAuthFile != "" || SMTPAuthAcceptAny) && !SMTPAuthAllowInsecure {
return errors.New("SMTP authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication")
}
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
SMTPTags = []AutoTag{}
p := shellwords.NewParser()
if SMTPCLITags != "" {
args, err := p.Parse(SMTPCLITags)
if err != nil {
return fmt.Errorf("Error parsing tags (%s)", err)
}
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := tools.CleanTag(t[0])
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("Invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
if len(match) == 0 {
return fmt.Errorf("Invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("Error parsing tags (%s)", a)
}
}
}
if err := parseRelayConfig(SMTPRelayConfigFile); err != nil {
return err
}
if !ReleaseEnabled && SMTPRelayAllIncoming {
return errors.New("SMTP relay config must be set to relay all messages")
}
if SMTPRelayAllIncoming {
// this deserves a warning
logger.Log().Warnf("[smtp] enabling automatic relay of all new messages via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port)
}
return nil
}
// Parse & validate the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {
return nil
}
if !isFile(c) {
return fmt.Errorf("SMTP relay configuration not found: %s", SMTPRelayConfigFile)
}
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")
}
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 (%s)", c)
}
} else if SMTPRelayConfig.Auth == "login" {
SMTPRelayConfig.Auth = "login"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Password == "" {
return fmt.Errorf("SMTP relay host username or password not set for LOGIN authentication (%s)", c)
}
} else if strings.HasPrefix(SMTPRelayConfig.Auth, "cram") {
SMTPRelayConfig.Auth = "cram-md5"
if SMTPRelayConfig.Username == "" || SMTPRelayConfig.Secret == "" {
return fmt.Errorf("SMTP relay host username or secret not set for CRAM-MD5 authentication (%s)", c)
}
} 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)
allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.RecipientAllowlist)
if SMTPRelayConfig.RecipientAllowlist != "" {
if err != nil {
return fmt.Errorf("failed to compile recipient allowlist regexp: %e", err)
}
SMTPRelayConfig.RecipientAllowlistRegexp = allowlistRegexp
logger.Log().Infof("[smtp] recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.RecipientAllowlist)
SMTPAuth = a
}
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
}

View File

@@ -1,118 +0,0 @@
# Message
## Message summary
Returns a JSON summary of the message and attachments.
**URL** : `api/v1/message/<ID>`
**Method** : `GET`
## Response
**Status** : `200`
```json
{
"ID": "d7a5543b-96dd-478b-9b60-2b465c9884de",
"MessageID": "12345.67890@localhost",
"Read": true,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [],
"Bcc": [],
"ReplyTo": [],
"Subject": "Message subject",
"Date": "2016-09-07T16:46:00+13:00",
"Tags": ["test"],
"Text": "Plain text MIME part of the email",
"HTML": "HTML MIME part (if exists)",
"Size": 79499,
"Inline": [
{
"PartID": "1.2",
"FileName": "filename.gif",
"ContentType": "image/gif",
"ContentID": "919564503@07092006-1525",
"Size": 7760
}
],
"Attachments": [
{
"PartID": "2",
"FileName": "filename.doc",
"ContentType": "application/msword",
"ContentID": "",
"Size": 43520
}
]
}
```
### Notes
- `Read` - always true (message marked read on open)
- `From` - Name & Address, or null
- `To`, `CC`, `BCC`, `ReplyTo` - Array of Names & Address
- `Date` - Parsed email local date & time from headers
- `Size` - Total size of raw email
- `Inline`, `Attachments` - Array of attachments and inline images.
---
## Attachments
**URL** : `api/v1/message/<ID>/part/<PartID>`
**Method** : `GET`
Returns the attachment using the MIME type provided by the attachment `ContentType`.
---
## Headers
**URL** : `api/v1/message/<ID>/headers`
**Method** : `GET`
Returns all message headers as a JSON array.
Each unique header key contains an array of one or more values (email headers can be listed multiple times.)
```json
{
"Content-Type": [
"multipart/related; type=\"multipart/alternative\"; boundary=\"----=_NextPart_000_0013_01C6A60C.47EEAB80\""
],
"Date": [
"Wed, 12 Jul 2006 23:38:30 +1200"
],
"Delivered-To": [
"user@example.com",
"user-alias@example.com"
],
"From": [
"\"User Name\" \\u003remote@example.com\\u003e"
],
"Message-Id": [
"\\u003c001701c6a5a7$b3205580$0201010a@HomeOfficeSM\\u003e"
],
....
}
```
---
## Raw (source) email
**URL** : `api/v1/message/<ID>/raw`
**Method** : `GET`
Returns the original email source including headers and attachments.

View File

@@ -1,169 +0,0 @@
# Messages
List & delete messages.
---
## List
List messages in the mailbox. Messages are returned in the order of latest received to oldest.
**URL** : `api/v1/messages`
**Method** : `GET`
### Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
### Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"messages_count": 50,
"start": 0,
"tags": ["test"],
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Message subject",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Tags": ["test"],
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox
- `unread` - Total unread messages in mailbox
- `messages_count` - Total number of messages in mailbox
- `start` - The offset (default `0`) for pagination
- `Read` - The read/unread status of the message
- `From` - Name & Address, or null if none
- `To`, `CC`, `BCC` - Array of Names & Address
- `Created` - Local date & time the message was received
- `Size` - Total size of raw email in bytes
---
## Delete individual messages
Delete one or more messages by ID.
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": ["<ID>","<ID>"...]
}
```
### Response
**Status** : `200`
---
## Delete all messages
Delete all messages (same as deleting individual messages, but with the "ids" either empty or omitted entirely).
**URL** : `api/v1/messages`
**Method** : `DELETE`
### Request
```json
{
"ids": []
}
```
### Response
**Status** : `200`
---
## Update individual read statuses
Set the read status of one or more messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"read": false
}
```
### Response
**Status** : `200`
---
## Update all messages read status
Set the read status of all messages.
The `read` status can be `true` or `false`.
**URL** : `api/v1/messages`
**Method** : `PUT`
### Request
```json
{
"ids": [],
"read": false
}
```
### Response
**Status** : `200`

View File

@@ -1,14 +0,0 @@
# API v1
Mailpit provides a simple REST API to access and delete stored messages.
If the Mailpit server is set to use Basic Authentication, then API requests must use Basic Authentication too.
You can view the Swagger API documentation directly within Mailpit by going to `http://0.0.0.0:8025/api/v1/`.
The API is split into three main parts:
- [Messages](Messages.md) - Listing, deleting & marking messages as read/unread.
- [Message](Message.md) - Return message data & attachments
- [Tags](Tags.md) - Set message tags
- [Search](Search.md) - Searching messages

View File

@@ -1,70 +0,0 @@
# Search
**URL** : `api/v1/search?query=<string>`
**Method** : `GET`
The search returns the most recent matches (default 50).
Matching messages are returned in the order of latest received to oldest.
## Query parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|----------------------------|
| query | string | true | Search query |
| limit | integer | false | Limit results (default 50) |
| start | integer | false | Pagination offset |
## Response
**Status** : `200`
```json
{
"total": 500,
"unread": 500,
"messages_count": 25,
"start": 0,
"messages": [
{
"ID": "1c575821-70ba-466f-8cee-2e1cf0fcdd0f",
"MessageID": "12345.67890@localhost",
"Read": false,
"From": {
"Name": "John Doe",
"Address": "john@example.com"
},
"To": [
{
"Name": "Jane Smith",
"Address": "jane@example.com"
}
],
"Cc": [
{
"Name": "Accounts",
"Address": "accounts@example.com"
}
],
"Bcc": [],
"Subject": "Test email",
"Created": "2022-10-03T21:35:32.228605299+13:00",
"Size": 6144,
"Attachments": 0
},
...
]
}
```
### Notes
- `total` - Total messages in mailbox (all messages, not search)
- `unread` - Total unread messages in mailbox (all messages, not search)
- `messages_count` - Total number of messages matching search
- `start` - The offset (default `0`) for pagination
- `From` - Singular Name & Address, or null if none
- `To`, `CC`, `BCC` - Array of Name & Address
- `Size` - Total size of raw email in bytes

View File

@@ -1,27 +0,0 @@
# Tags
Set message tags.
---
## Update message tags
Set the tags for one or more messages.
If the tags array is empty then all tags are removed from the messages.
**URL** : `api/v1/tags`
**Method** : `PUT`
### Request
```json
{
"ids": ["<ID>","<ID>"...],
"tags": ["<tag>","<tag>"]
}
```
### Response
**Status** : `200`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

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,37 +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',
},
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()
}

72
go.mod
View File

@@ -4,68 +4,60 @@ go 1.18
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/PuerkitoBio/goquery v1.8.1
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v1.0.0
github.com/k3a/html2text v1.2.1
github.com/klauspost/compress v1.16.7
github.com/leporo/sqlf v1.4.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/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.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.1
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.14.0
golang.org/x/text v0.12.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.25.0
github.com/tg123/go-htpasswd v1.2.0
golang.org/x/text v0.3.7
modernc.org/sqlite v1.18.1
)
require (
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cznic/ql v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // 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.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // 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.4 // 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.12.0 // indirect
golang.org/x/image v0.11.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/tools v0.12.0 // indirect
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/tools v0.1.12 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.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.2.0 // indirect
modernc.org/token v1.1.0 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

226
go.sum
View File

@@ -1,16 +1,9 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/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/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
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=
@@ -42,43 +35,43 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/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/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
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.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.0.0 h1:8swYgO1fm68PllCKz5jiLzgD3axNUS388jr6BtRSsl8=
github.com/jhillyerd/enmime v1.0.0/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
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.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
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.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
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=
@@ -86,17 +79,19 @@ 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/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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=
@@ -105,145 +100,126 @@ 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.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
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 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
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.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
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-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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
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/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
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-20200323222414-85ca7c5b95cd/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-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.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
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.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
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=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
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/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.0 h1:2pXdbgdP5hIyDp2JqIwkHNZ1sAjEbh8GnRpcqFWBf7E=
modernc.org/memory v1.7.0/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
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

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))
}

3059
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,23 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "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",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0"
"vue": "^3.2.13"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.18.10",
"esbuild": "^0.14.50",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,47 +1,24 @@
// 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"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/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() {
// Run the Mailpit sendmail replacement.
func Run() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
@@ -53,76 +30,36 @@ func init() {
username = user.Username
}
if FromAddr == "" {
FromAddr = username + "@" + host
}
}
// Run the Mailpit sendmail replacement.
func Run() {
var recipients []string
fromAddr := username + "@" + host
smtpAddr := "localhost:1025"
var recip []string
// 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)
@@ -130,55 +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"))
}
err = smtp.SendMail(SMTPAddr, nil, FromAddr, 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,785 +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/server/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/htmlcheck"
"github.com/axllent/mailpit/utils/linkcheck"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/gorilla/mux"
uuid "github.com/satori/go.uuid"
)
// 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 = len(messages) // legacy - now undocumented in API specs
res.Total = stats.Total
res.Unread = stats.Unread
res.Tags = stats.Tags
res.MessagesCount = stats.Total
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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 the latest messages matching a search.
//
// 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
//
// 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, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet()
var res MessagesSummary
res.Start = start
res.Messages = messages
res.Count = results // legacy - now undocumented in API specs
res.Total = stats.Total
res.MessagesCount = results
res.Unread = stats.Unread
res.Tags = stats.Tags
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: Message
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
msg, err := storage.GetMessage(id)
if err != nil {
fourOFour(w)
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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: 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.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: MessageHeaders
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
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
}
bytes, _ := json.Marshal(m.Header)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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.
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: TextResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
dl := r.FormValue("dl")
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 Delete
//
// # Delete messages
//
// If no IDs are provided then all messages are deleted.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to delete
// required: false
// type: DeleteRequest
//
// 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 {
for _, id := range data.IDs {
if err := storage.DeleteOneMessage(id); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs
// If no IDs are provided then all messages are updated.
func SetReadStatus(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/messages messages SetReadStatus
//
// # Set read status
//
// If no IDs are provided then all messages are updated.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: false
// type: SetReadStatusRequest
//
// 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"))
}
// SetTags (method: PUT) will set the tags for all provided IDs
func SetTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
//
// # Set message tags
//
// To remove all tags from a message, pass an empty tags array.
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ids
// in: body
// description: Database IDs to update
// required: true
// type: SetTagsRequest
//
// 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.SetTags(id, data.Tags); err != nil {
httpError(w, err.Error())
return
}
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server.
// If no IDs are provided then all messages are updated.
func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/message/{ID}/release message Release
//
// # Release message
//
// Release a message via a pre-configured external SMTP server..
//
// Consumes:
// - application/json
//
// Produces:
// - text/plain
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: to
// in: body
// description: Array of email addresses to release message to
// required: true
// type: ReleaseMessageRequest
//
// 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 := releaseMessageRequest{}
if err := decoder.Decode(&data); err != nil {
httpError(w, err.Error())
return
}
tos := data.To
if len(tos) == 0 {
httpError(w, "No valid addresses found")
return
}
for _, to := range tos {
address, err := mail.ParseAddress(to)
if err != nil {
httpError(w, "Invalid email address: "+to)
return
}
if config.SMTPRelayConfig.RecipientAllowlistRegexp != nil && !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
httpError(w, "Mail address does not match allowlist: "+to)
return
}
}
reader := bytes.NewReader(msg)
m, err := mail.ReadMessage(reader)
if err != nil {
httpError(w, err.Error())
return
}
froms, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
from := froms[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", "Message-Id"})
if err != nil {
httpError(w, err.Error())
return
}
// set the Return-Path and SMTP mfrom
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
}
// generate unique ID
uid := uuid.NewV4().String() + "@mailpit"
// add unique ID
msg = append([]byte("Message-Id: <"+uid+">\r\n"), msg...)
if err := smtpd.Send(from, tos, 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"))
}
// 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 HTMLCheckResponse
//
// # HTML check (beta)
//
// Returns the summary of the message HTML checker.
//
// NOTE: This feature is currently in beta and is documented for reference only.
// Please do not integrate with it (yet) as there may be changes.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
//
// Responses:
// 200: HTMLCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
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
}
bytes, _ := json.Marshal(checks)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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 LinkCheckResponse
//
// # Link check (beta)
//
// Returns the summary of the message Link checker.
//
// NOTE: This feature is currently in beta and is documented for reference only.
// Please do not integrate with it (yet) as there may be changes.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: follow
// in: query
// description: Follow redirects
// required: false
// type: boolean
// default: false
//
// Responses:
// 200: LinkCheckResponse
// default: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
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
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}
// 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)
}
// 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, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(""))
}

View File

@@ -1,74 +0,0 @@
package apiv1
import (
"encoding/json"
"net/http"
"os"
"runtime"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/updater"
)
// Response includes the current and latest Mailpit version, database info, and memory usage
//
// 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 int64
// Total number of messages in the database
Messages int
// Current memory usage in bytes
Memory uint64
}
// AppInfo returns some basic details about the running app, and latest release.
func AppInfo(w http.ResponseWriter, r *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
info := appInformation{}
info.Version = config.Version
var m runtime.MemStats
runtime.ReadMemStats(&m)
info.Memory = m.Sys - m.HeapReleased
latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName)
if err == nil {
info.LatestVersion = latest
}
info.Database = config.DataFile
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Messages = storage.CountTotal()
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

View File

@@ -1,55 +0,0 @@
package apiv1
import (
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/htmlcheck"
"github.com/axllent/mailpit/utils/linkcheck"
)
// MessagesSummary is a summary of a list of messages
type MessagesSummary struct {
// Total number of messages in mailbox
Total int `json:"total"`
// Total number of unread messages in mailbox
Unread int `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12
// swagger:ignore
Count int `json:"count"`
// Total number of messages matching current query
MessagesCount int `json:"messages_count"`
// // Number of results returned on current page
// Count int `json:"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

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,98 +0,0 @@
package apiv1
// These structs are for the purpose of defining swagger HTTP responses
// Application information
// swagger:response InfoResponse
type infoResponse struct {
// Application information
Body appInformation
}
// Web UI configuration
// swagger:response WebUIConfigurationResponse
type webUIConfigurationResponse struct {
// Web UI configuration settings
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
// Delete request
// swagger:model DeleteRequest
type deleteRequest struct {
// ids
// in:body
IDs []string `json:"ids"`
}
// Set read status request
// swagger:model SetReadStatusRequest
type setReadStatusRequest struct {
// Read status
Read bool `json:"read"`
// ids
// in:body
IDs []string `json:"ids"`
}
// Set tags request
// swagger:model SetTagsRequest
type setTagsRequest struct {
// Tags
// in:body
Tags []string `json:"tags"`
// IDs
// in:body
IDs []string `json:"ids"`
}
// Release request
// swagger:model ReleaseMessageRequest
type releaseMessageRequest struct {
// To
// in:body
To []string `json:"to"`
}
// Binary data response inherits the attachment's content type
// swagger:response BinaryResponse
type binaryResponse struct {
// in: body
Body string
}
// Plain text response
// swagger:response TextResponse
type textResponse struct {
// in: body
Body string
}
// Error response
// swagger:response ErrorResponse
type errorResponse struct {
// The error message
// in: body
Body string
}
// Plain text "ok" response
// swagger:response OKResponse
type okResponse struct {
// Default response
// in: body
Body string
}

View File

@@ -1,63 +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 {
// 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
// Allowlist of accepted recipients
RecipientAllowlist string
}
// Whether the HTML check has been globally disabled
DisableHTMLCheck 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.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.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
}
conf.DisableHTMLCheck = config.DisableHTMLCheck
bytes, _ := json.Marshal(conf)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
}

View File

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

View File

@@ -1,17 +0,0 @@
package handlers
import (
"net/http"
"sync/atomic"
)
// 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) {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}

View File

@@ -1,37 +1,30 @@
// Package server is the HTTP daemon
package server
import (
"bytes"
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/mux"
)
//go:embed ui
var embeddedFS embed.FS
// AccessControlAllowOrigin CORS policy
var AccessControlAllowOrigin string
var contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src 'self' data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';"
// Listen will start the httpd
func Listen() {
isReady := &atomic.Value{}
isReady.Store(false)
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err)
@@ -42,71 +35,38 @@ func Listen() {
go websockets.MessageHub.Run()
r := defaultRoutes()
// kubernetes probes
r.HandleFunc("/livez", handlers.HealthzHandler)
r.HandleFunc("/readyz", handlers.ReadyzHandler(isReady))
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
r := mux.NewRouter()
r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)).Methods("GET")
r.HandleFunc("/api/messages", middleWareFunc(apiListMessages)).Methods("GET")
r.HandleFunc("/api/search", middleWareFunc(apiSearchMessages)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteSelected)).Methods("POST")
r.HandleFunc("/api/events", apiWebsocket).Methods("GET")
r.HandleFunc("/api/read", apiMarkAllRead).Methods("GET")
r.HandleFunc("/api/read", apiMarkSelectedRead).Methods("POST")
r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST")
r.HandleFunc("/api/{id}/raw", middleWareFunc(apiDownloadRaw)).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}/thumb", middleWareFunc(apiAttachmentThumbnail)).Methods("GET")
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET")
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET")
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET")
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
http.Handle("/", r)
if config.UIAuthFile != "" {
logger.Log().Info("[http] enabling web UI basic authentication")
}
// Mark the application here as ready
isReady.Store(true)
if config.UITLSCert != "" && config.UITLSKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UITLSCert, config.UITLSKey, nil))
if config.UISSLCert != "" && config.UISSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s%s", logger.CleanHTTPIP(config.HTTPListen), config.Webroot)
logger.Log().Fatal(http.ListenAndServe(config.HTTPListen, nil))
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
func defaultRoutes() *mux.Router {
r := mux.NewRouter()
// API V1
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
if !config.DisableHTMLCheck {
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
}
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
return r
}
// BasicAuthResponse returns an basic auth response to the browser
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
@@ -128,13 +88,7 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -167,13 +121,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
}
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
@@ -200,30 +148,38 @@ func middlewareHandler(h http.Handler) http.Handler {
})
}
// Redirect to webroot
func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
// FourOFour returns a basic 404 message
func fourOFour(w http.ResponseWriter) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
// 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", contentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
f, err := embeddedFS.ReadFile("ui/api/v1/swagger.json")
if err != nil {
panic(err)
// 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
}
if config.Webroot != "/" {
// artificially inject a path at the start
replacement := fmt.Sprintf("{\n \"basePath\": \"%s\",", strings.TrimRight(config.Webroot, "/"))
f = bytes.Replace(f, []byte("{"), []byte(replacement), 1)
l := req.URL.Query().Get("limit")
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(f)
return start, limit
}

View File

@@ -1,317 +0,0 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
var (
putDataStruct struct {
Read bool `json:"read"`
IDs []string `json:"ids"`
}
)
func Test_APIv1(t *testing.T) {
setup()
defer storage.Close()
r := defaultRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
m, err := fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// check count of empty database
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
// insert 100
t.Log("Insert 100 messages")
insertEmailData(t)
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// store this for later tests
m, err = fetchMessages(ts.URL + "/api/v1/messages")
if err != nil {
t.Errorf(err.Error())
}
// read first 10
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for indx, msg := range m.Messages {
if indx == 10 {
break
}
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil {
t.Errorf(err.Error())
}
// test RAW
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil {
t.Errorf(err.Error())
}
// test headers
if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil {
t.Errorf(err.Error())
}
// store for later
putIDS = append(putIDS, msg.ID)
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// mark first 10 as unread
t.Log("Mark first 10 as unread")
putData := putDataStruct
putData.IDs = putIDS
j, err := json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100)
// mark first 10 as read
t.Log("Mark first 10 as read")
putData.Read = true
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100)
// search
assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "to:from-1@example.com", 0)
assertSearchEqual(t, ts.URL+"/api/v1/search", "from:@example.com", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line\"", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "subject:\"Subject line 17 end\"", 1)
assertSearchEqual(t, ts.URL+"/api/v1/search", "!thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "-thisdoesnotexist", 100)
assertSearchEqual(t, ts.URL+"/api/v1/search", "thisdoesnotexist", 0)
// delete first 10
t.Log("Delete first 10")
_, err = clientDelete(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 90)
// mark all as read
putData.Read = true
putData.IDs = []string{}
j, err = json.Marshal(putData)
if err != nil {
t.Errorf(err.Error())
}
t.Log("Mark all read")
_, err = clientPut(ts.URL+"/api/v1/messages", string(j))
if err != nil {
t.Errorf(err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 90)
// delete all
t.Log("Delete all messages")
_, err = clientDelete(ts.URL+"/api/v1/messages", "{}")
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 0)
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := storage.InitDB(); err != nil {
panic(err)
}
}
func assertStatsEqual(t *testing.T, uri string, unread, total int) {
m := apiv1.MessagesSummary{}
data, err := clientGet(uri)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, unread, m.Unread, "wrong unread count")
assertEqual(t, total, m.Total, "wrong total count")
}
func assertSearchEqual(t *testing.T, uri, query string, count int) {
t.Logf("Test search: %s", query)
m := apiv1.MessagesSummary{}
limit := fmt.Sprintf("%d", count)
data, err := clientGet(uri + "?query=" + url.QueryEscape(query) + "&limit=" + limit)
if err != nil {
t.Errorf(err.Error())
return
}
if err := json.Unmarshal(data, &m); err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, m.MessagesCount, "wrong search results count")
}
func insertEmailData(t *testing.T) {
for i := 0; i < 100; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]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))
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()
}
if _, err := storage.Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
m := apiv1.MessagesSummary{}
data, err := clientGet(url)
if err != nil {
return m, err
}
if err := json.Unmarshal(data, &m); err != nil {
return m, err
}
return m, nil
}
func clientGet(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
return data, err
}
func clientDelete(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("DELETE", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
return data, err
}
func clientPut(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("PUT", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
return data, 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)
}

View File

@@ -1,140 +0,0 @@
package smtpd
import (
"crypto/tls"
"errors"
"fmt"
"net/mail"
"net/smtp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
)
func allowedRecipients(to []string) []string {
if config.SMTPRelayConfig.RecipientAllowlistRegexp == nil {
return to
}
var ar []string
for _, recipient := range to {
address, err := mail.ParseAddress(recipient)
if err != nil {
logger.Log().Warnf("ignoring invalid email address: %s", recipient)
continue
}
if !config.SMTPRelayConfig.RecipientAllowlistRegexp.MatchString(address.Address) {
logger.Log().Debugf("[smtp] not allowed to relay to %s: does not match the allowlist %s", recipient, config.SMTPRelayConfig.RecipientAllowlist)
} else {
ar = append(ar, recipient)
}
}
return ar
}
// Send will connect to a pre-configured SMTP server and send a message to one or more recipients.
func Send(from string, to []string, msg []byte) error {
recipients := allowedRecipients(to)
if len(recipients) == 0 {
return errors.New("no valid recipients")
}
addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("error connecting to %s: %s", addr, err.Error())
}
defer c.Close()
if config.SMTPRelayConfig.STARTTLS {
conf := &tls.Config{ServerName: config.SMTPRelayConfig.Host}
conf.InsecureSkipVerify = config.SMTPRelayConfig.AllowInsecure
if err = c.StartTLS(conf); err != nil {
return fmt.Errorf("error creating StartTLS config: %s", err.Error())
}
}
var a smtp.Auth
if config.SMTPRelayConfig.Auth == "plain" {
a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host)
}
if config.SMTPRelayConfig.Auth == "login" {
a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password)
}
if config.SMTPRelayConfig.Auth == "cram-md5" {
a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret)
}
if a != nil {
if err = c.Auth(a); err != nil {
return fmt.Errorf("error response to AUTH command: %s", err.Error())
}
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("error response to MAIL command: %s", err.Error())
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error())
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("error response to DATA command: %s", err.Error())
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("error sending message: %s", err.Error())
}
if err := w.Close(); err != nil {
return fmt.Errorf("error closing connection: %s", err.Error())
}
return c.Quit()
}
// Custom implementation of LOGIN SMTP authentication
// @see https://gist.github.com/andelf/5118732
type loginAuth struct {
username, password string
}
// LoginAuth authentication
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("Unknown fromServer")
}
}
return nil, nil
}

View File

@@ -1,236 +0,0 @@
// Package smtpd is the SMTP daemon
package smtpd
import (
"bytes"
"fmt"
"net"
"net/mail"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/mhale/smtpd"
uuid "github.com/satori/go.uuid"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if !config.SMTPStrictRFCHeaders {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n"))
}
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
return err
}
// check / set the Return-Path based on SMTP from
returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>")
if returnPath != from {
if returnPath != "" {
// replace Return-Path
re := regexp.MustCompile(`(?i)(^|\n)(Return\-Path: .*\n)`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurrence
return re.ReplaceAll(r, []byte("${1}Return-Path: <"+from+">\r\n"))
})
} else {
// add Return-Path
data = append([]byte("Return-Path: <"+from+">\r\n"), data...)
}
}
messageID := strings.Trim(msg.Header.Get("Message-Id"), "<>")
// add a message ID if not set
if messageID == "" {
// generate unique ID
messageID = uuid.NewV4().String() + "@mailpit"
// add unique ID
data = append([]byte("Message-Id: <"+messageID+">\r\n"), data...)
} else if config.IgnoreDuplicateIDs {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
return nil
}
}
// if enabled, this will route the email 1:1 through to the preconfigured smtp server
if config.SMTPRelayAllIncoming {
if err := Send(from, to, data); err != nil {
logger.Log().Warnf("[smtp] error relaying message: %s", err.Error())
} else {
logger.Log().Debugf("[smtp] relayed message from %s via %s:%d", from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)
}
}
// build array of all addresses in the header to compare to the []to array
emails, hasBccHeader := scanAddressesInHeader(msg.Header)
missingAddresses := []string{}
for _, a := range to {
// loop through passed email addresses to check if they are in the headers
if _, err := mail.ParseAddress(a); err == nil {
_, ok := emails[strings.ToLower(a)]
if !ok {
missingAddresses = append(missingAddresses, a)
}
} else {
logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a)
}
}
// add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers)
if len(missingAddresses) > 0 {
if hasBccHeader {
// email already has Bcc header, add to existing addresses
re := regexp.MustCompile(`(?i)(^|\n)(Bcc: )`)
replaced := false
data = re.ReplaceAllFunc(data, func(r []byte) []byte {
if replaced {
return r
}
replaced = true // only replace first occurrence
return re.ReplaceAll(r, []byte("${1}Bcc: "+strings.Join(missingAddresses, ", ")+", "))
})
} else {
// prepend new Bcc header
bcc := []byte(fmt.Sprintf("Bcc: %s\r\n", strings.Join(missingAddresses, ", ")))
data = append(bcc, data...)
}
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
_, err = storage.Store(data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
allow := config.SMTPAuthConfig.Match(string(username), string(password))
if allow {
logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
} else {
logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr))
}
return allow, nil
}
// Allow any username and password
func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) {
logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr))
return true, nil
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPAuthAllowInsecure {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtpd] enabling login auth via %s (insecure)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling all auth (insecure)")
}
} else {
if config.SMTPAuthFile != "" {
logger.Log().Infof("[smtpd] enabling login auth via %s (TLS)", config.SMTPAuthFile)
} else if config.SMTPAuthAcceptAny {
logger.Log().Info("[smtpd] enabling any auth (TLS)")
}
}
logger.Log().Infof("[smtpd] starting on %s", logger.CleanIP(config.SMTPListen))
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}
if config.SMTPAuthFile != "" {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandler
srv.AuthRequired = true
} else if config.SMTPAuthAcceptAny {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
srv.AuthHandler = authHandlerAny
}
if config.SMTPTLSCert != "" {
if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil {
return err
}
}
return srv.ListenAndServe()
}
func cleanIP(i net.Addr) string {
parts := strings.Split(i.String(), ":")
return parts[0]
}
// Returns a list of all lowercased emails found in To, Cc and Bcc,
// as well as whether there is a Bcc field
func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) {
emails := make(map[string]bool)
hasBccHeader := false
if recipients, err := h.AddressList("To"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
if recipients, err := h.AddressList("Cc"); err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
}
recipients, err := h.AddressList("Bcc")
if err == nil {
for _, r := range recipients {
emails[strings.ToLower(r.Address)] = true
}
hasBccHeader = true
}
return emails, hasBccHeader
}

View File

@@ -1,4 +1,4 @@
package apiv1
package server
import (
"bufio"
@@ -10,8 +10,8 @@ import (
"net/http"
"strings"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
@@ -22,35 +22,8 @@ var (
thumbHeight = 120
)
// Thumbnail returns a thumbnail image for an attachment (images only)
func Thumbnail(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message Thumbnail
//
// # Get an attachment image thumbnail
//
// This will return a cropped 180x120 JPEG thumbnail of an image attachment.
// If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.
//
// Produces:
// - image/jpeg
//
// Schemes: http, https
//
// Parameters:
// + name: ID
// in: path
// description: Database ID
// required: true
// type: string
// + name: PartID
// in: path
// description: Attachment part ID
// required: true
// type: string
//
// Responses:
// 200: BinaryResponse
// default: ErrorResponse
// Attachment thumbnail (images only)
func apiAttachmentThumbnail(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/bootstrap.scss";
import "./assets/styles.scss";
import "bootstrap-icons/font/bootstrap-icons.scss";
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
createApp(App).mount('#app');
createApp(App).mount('#app')

View File

@@ -1,9 +1,2 @@
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans",
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;
$body-color-dark: #e7eaed;
$primary: #3465b5;

View File

@@ -2,50 +2,48 @@
// scss-docs-start import-stack
// Configuration
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
@import "../../../node_modules/bootstrap/scss/functions";
@import "../../../node_modules/bootstrap/scss/variables";
@import "../../../node_modules/bootstrap/scss/maps";
@import "../../../node_modules/bootstrap/scss/mixins";
@import "../../../node_modules/bootstrap/scss/utilities";
// Layout & components
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
@import "bootstrap/scss/images";
@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
// @import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
@import "bootstrap/scss/card";
@import "bootstrap/scss/accordion";
// @import "bootstrap/scss/breadcrumb";
// @import "bootstrap/scss/pagination";
@import "bootstrap/scss/badge";
@import "bootstrap/scss/alert";
// @import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
// @import "bootstrap/scss/popover";
// @import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
// @import "bootstrap/scss/offcanvas";
// @import "bootstrap/scss/popover";
@import "bootstrap/scss/progress";
@import "../../../node_modules/bootstrap/scss/root";
@import "../../../node_modules/bootstrap/scss/reboot";
@import "../../../node_modules/bootstrap/scss/type";
@import "../../../node_modules/bootstrap/scss/images";
@import "../../../node_modules/bootstrap/scss/containers";
@import "../../../node_modules/bootstrap/scss/grid";
// @import "../../../node_modules/bootstrap/scss/tables";
@import "../../../node_modules/bootstrap/scss/forms";
@import "../../../node_modules/bootstrap/scss/buttons";
// @import "../../../node_modules/bootstrap/scss/transitions";
@import "../../../node_modules/bootstrap/scss/dropdown";
@import "../../../node_modules/bootstrap/scss/button-group";
@import "../../../node_modules/bootstrap/scss/nav";
@import "../../../node_modules/bootstrap/scss/navbar";
@import "../../../node_modules/bootstrap/scss/card";
// @import "../../../node_modules/bootstrap/scss/accordion";
// @import "../../../node_modules/bootstrap/scss/breadcrumb";
// @import "../../../node_modules/bootstrap/scss/pagination";
@import "../../../node_modules/bootstrap/scss/badge";
// @import "../../../node_modules/bootstrap/scss/alert";
// @import "../../../node_modules/bootstrap/scss/progress";
@import "../../../node_modules/bootstrap/scss/list-group";
@import "../../../node_modules/bootstrap/scss/close";
// @import "../../../node_modules/bootstrap/scss/toasts";
@import "../../../node_modules/bootstrap/scss/modal";
// @import "../../../node_modules/bootstrap/scss/tooltip";
// @import "../../../node_modules/bootstrap/scss/popover";
// @import "../../../node_modules/bootstrap/scss/carousel";
@import "../../../node_modules/bootstrap/scss/spinners";
// @import "../../../node_modules/bootstrap/scss/offcanvas";
// @import "../../../node_modules/bootstrap/scss/popover";
// Helpers
@import "bootstrap/scss/helpers";
@import "../../../node_modules/bootstrap/scss/helpers";
// Utilities
@import "bootstrap/scss/utilities/api";
@import "../../../node_modules/bootstrap/scss/utilities/api";
// scss-docs-end import-stack

View File

@@ -1,4 +1,4 @@
@import "./bootstrap";
@import "bootstrap";
[v-cloak] {
display: none !important;
@@ -9,7 +9,6 @@
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
@@ -25,45 +24,6 @@
}
}
.navbar-brand {
span {
opacity: 0.8;
transition: all 0.5s;
}
&:hover {
span {
opacity: 1;
}
}
}
.nav-tabs .nav-link {
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
}
:not(.text-view) > a:not(.no-icon) {
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
}
#loading {
position: absolute;
top: 0;
@@ -74,46 +34,17 @@
z-index: 1500;
}
// dark mode adjustments
@include color-mode(dark) {
#loading {
background: rgba(0, 0, 0, 0.4);
}
.token.tag,
.token.property {
color: #ee6969;
}
}
.message {
&.read {
color: $text-muted;
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
.message.read:not(.active):not(.selected) {
color: $gray-500;
}
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family:
Courier New,
Courier,
System,
fixed-width;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-html-source pre[class*="language-"] code {
white-space: pre-wrap;
}
#nav-plain-text .text-view {
white-space: pre-wrap;
}
@@ -133,102 +64,32 @@
}
#nav-html {
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
padding-right: 1.5rem;
}
#preview-html {
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
margin: auto;
transition: width 0.5s;
position: relative;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
iframe {
height: 100% !important;
background: #fff;
}
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
}
.list-group-item.message:first-child {
.list-group-item:first-child {
border-top: 0;
}
.message.selected {
background: $primary;
color: #fff;
.text-muted {
color: #fff !important;
}
&.read {
b {
font-weight: normal;
}
}
}
body.blur {
.privacy {
filter: blur(3px);
@@ -275,48 +136,12 @@ body.blur {
}
}
.form-select.tag-selector {
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
}
#ReleaseModal {
.form-control.dropdown {
div {
@extend .form-control;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
// color: #000;
// background: 0 0;
color: #000;
background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
@@ -349,7 +174,7 @@ code[class*="language-"] {
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
// background-color: #fdfdfd;
background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
@@ -399,7 +224,7 @@ pre[class*="language-"] {
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
@@ -414,7 +239,7 @@ pre[class*="language-"] {
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
@@ -425,15 +250,15 @@ pre[class*="language-"] {
.token.italic {
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
pre[class*="language-"]:after,
pre[class*="language-"]:before {
bottom: 14px;
box-shadow: none;
}

View File

@@ -1 +0,0 @@
import "rapidoc";

View File

@@ -1,32 +1,19 @@
import axios from 'axios'
import { Modal } from 'bootstrap'
import moment from 'moment'
import ColorHash from 'color-hash'
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing to prevent errors.
// if the ID returns nothing
function FakeModal() { }
FakeModal.prototype.hide = function () { }
FakeModal.prototype.show = function () { }
// Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
FakeModal.prototype.hide = function () { alert('close fake modal') }
FakeModal.prototype.show = function () { alert('open fake modal') }
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0,
tagColorCache: {},
showTagColors: true
}
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -37,10 +24,6 @@ const commonMixins = {
return new Intl.NumberFormat().format(nr);
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
},
// Ajax error message
handleError: function (error) {
// handle error
@@ -48,7 +31,7 @@ const commonMixins = {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error);
alert(error.response.data.Error)
} else {
alert(error.response.data);
}
@@ -67,7 +50,17 @@ const commonMixins = {
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return Modal.getOrCreateInstance(e);
return bootstrap.Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
offcanvas: function (id) {
var e = document.getElementById(id);
if (e) {
return bootstrap.Offcanvas.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
@@ -95,16 +88,16 @@ const commonMixins = {
},
/**
* Axios POST request
* Axios Post request
*
* @params string url
* @params array object/array values
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, data, callback) {
post: function (url, values, callback) {
let self = this;
self.loading++;
axios.post(url, data)
axios.post(url, values)
.then(callback)
.catch(self.handleError)
.then(function () {
@@ -119,34 +112,13 @@ const commonMixins = {
* Axios DELETE request (REST only)
*
* @params string url
* @params array object/array values
* @params array array parameters Object/array
* @params function callback function
*/
delete: function (url, data, callback) {
delete: function (url, values, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios PUT request (REST only)
*
* @params string url
* @params array object/array values
* @params function callback function
*/
put: function (url, data, callback) {
let self = this;
self.loading++;
axios.put(url, data)
axios.delete(url, { data: values })
.then(callback)
.catch(self.handleError)
.then(function () {
@@ -211,30 +183,9 @@ const commonMixins = {
}
return 'bi-file-arrow-down-fill';
},
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}
this.tagColorCache[s] = colorHash.hex(s)
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
}
}
export default commonMixins;
export default commonMixins

View File

@@ -14,18 +14,14 @@ export default {
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="'api/v1/message/' + message.ID + '/part/' + part.PartID"
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
<img v-if="isImage(part)" :src="'api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb'"
class="card-img-top" alt="">
<img v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top" alt="">
<a v-for="part in attachments" :href="'api/'+message.ID+'/part/'+part.PartID" class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
<img v-if="isImage(part)" :src="'api/'+message.ID+'/part/'+part.PartID+'/thumb'" class="card-img-top" alt="">
<img v-else src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" class="card-img-top" alt="">
<div class="icon" v-if="!isImage(part)">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
<p class="mb-1">
<p class="mb-1 text-muted">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
@@ -33,7 +29,7 @@ export default {
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</div>
</a>

View File

@@ -1,38 +0,0 @@
<script>
import commonMixins from '../mixins.js'
export default {
props: {
message: Object
},
mixins: [commonMixins],
data() {
return {
headers: false
}
},
mounted() {
let self = this;
let uri = 'api/v1/message/' + self.message.ID + '/headers'
self.get(uri, false, function (response) {
self.headers = response.data
});
},
}
</script>
<template>
<div v-if="headers" class="small">
<div v-for="vals, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="x in vals" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>
</template>

View File

@@ -1,25 +1,17 @@
<script>
import commonMixins from '../mixins.js'
import Prism from "prismjs"
import Tags from "bootstrap5-tags"
import Attachments from './Attachments.vue'
import Headers from './Headers.vue'
import HTMLCheck from './MessageHTMLCheck.vue'
import LinkCheck from './MessageLinkCheck.vue'
import commonMixins from '../mixins.js';
import moment from 'moment';
import Prism from "prismjs";
import Attachments from './Attachments.vue';
export default {
props: {
message: Object,
existingTags: Array,
uiConfig: Object
message: Object
},
components: {
Attachments,
Headers,
HTMLCheck,
LinkCheck,
Attachments
},
mixins: [commonMixins],
@@ -28,238 +20,106 @@ export default {
return {
srcURI: false,
iframes: [], // for resizing
showTags: false, // to force re-rendering of component
canSaveTags: false, // prevent auto-saving tags on render
messageTags: [],
allTags: [],
loadHeaders: false,
htmlScore: false,
htmlScoreColor: false,
linkCheckErrors: false,
showMobileButtons: false,
scaleHTMLPreview: 'display',
// keys names match bootstrap icon names
responsiveSizes: {
phone: 'width: 322px; height: 570px',
tablet: 'width: 768px; height: 1024px',
display: 'width: 100%; height: 100%',
},
}
},
watch: {
// handle changes to the URL messageID
message: {
handler() {
let self = this
self.showTags = false
self.canSaveTags = false
self.messageTags = self.message.Tags
self.allTags = self.existingTags
self.loadHeaders = false
self.scaleHTMLPreview = 'display' // default view
// delay to select first tab and add HTML highlighting (prev/next)
self.$nextTick(function () {
self.renderUI()
self.showTags = true
self.$nextTick(function () {
Tags.init("select[multiple]")
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
})
})
handler(newQuestion) {
let self = this;
// delay 100ms to select first tab and add HTML highlighting (prev/next)
window.setTimeout(function() {
self.renderUI();
}, 100)
},
// force eager callback execution
immediate: true
},
messageTags() {
// save changes to tags
if (this.canSaveTags) {
this.saveTags()
}
},
scaleHTMLPreview() {
if (this.scaleHTMLPreview == 'display') {
let self = this
window.setTimeout(function () {
self.resizeIFrames()
}, 500)
}
}
},
mounted() {
let self = this
self.showTags = false
self.canSaveTags = false
self.allTags = self.existingTags
window.addEventListener("resize", self.resizeIFrames)
let headersTab = document.getElementById('nav-headers-tab')
headersTab.addEventListener('shown.bs.tab', function (event) {
self.loadHeaders = true
})
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw'
self.resizeIFrames()
})
self.showTags = true
self.$nextTick(function () {
self.$nextTick(function () {
Tags.init('select[multiple]')
window.setTimeout(function () {
self.canSaveTags = true
}, 200)
})
})
let self = this;
window.addEventListener("resize", self.resizeIframes);
self.renderUI();
var tabEl = document.getElementById('nav-raw-tab');
tabEl.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/' + self.message.ID + '/raw';
});
},
unmounted: function () {
window.removeEventListener("resize", this.resizeIFrames)
unmounted: function() {
window.removeEventListener("resize", this.resizeIframes);
},
methods: {
isHTMLTabSelected: function () {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () {
let self = this
renderUI: function() {
let self = this;
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click()
document.activeElement.blur() // blur focus
document.getElementById('message-view').scrollTop = 0
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
document.getElementById('message-view').scrollTop = 0;
self.isHTMLTabSelected()
window.setTimeout(function(){
let p = document.getElementById('preview-html');
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
listObj.addEventListener('shown.bs.tab', function (event) {
self.isHTMLTabSelected()
})
})
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () {
let p = document.getElementById('preview-html')
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i]
let href = anchorEl.getAttribute('href')
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank')
anchorEl.setAttribute('target', '_blank');
}
}
self.resizeIFrames()
self.resizeIframes();
}
}, 200)
}, 200);
// html highlighting
window.Prism = window.Prism || {}
window.Prism.manual = true
Prism.highlightAll()
window.Prism = window.Prism || {};
window.Prism.manual = true;
Prism.highlightAll();
},
resizeIframe: function(el) {
let i = el.target;
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
},
resizeIframe: function (el) {
let i = el.target
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
},
resizeIFrames: function () {
if (this.scaleHTMLPreview != 'display') {
return
}
let h = document.getElementById('preview-html')
resizeIframes: function() {
let h = document.getElementById('preview-html');
if (h) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
}
},
// set the iframe body & text colors based on current theme
initRawIframe: function (el) {
let bodyStyles = window.getComputedStyle(document.body, null)
let bg = bodyStyles.getPropertyValue('background-color')
let txt = bodyStyles.getPropertyValue('color')
let body = el.target.contentWindow.document.querySelector('body')
if (body) {
body.style.color = txt
body.style.backgroundColor = bg
let s = document.getElementById('message-src');
if (s) {
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
}
this.resizeIframe(el)
},
sanitizeHTML: function (h) {
// remove <base/> tag if set
return h.replace(/<base .*>/mi, '')
},
saveTags: function () {
let self = this
var data = {
ids: [this.message.ID],
tags: this.messageTags
}
self.put('api/v1/tags', data, function (response) {
self.scrollInPlace = true
self.$emit('loadMessages')
})
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
let html = s
// full links with http(s)
let re = /(\b(https?|ftp):\/\/[\-\w@:%_\+'!.~#?,&\/\/=;]+)/gim
html = html.replace(re, '˱˱˱a href=ˠˠˠ$&ˠˠˠ target=_blank rel=noopener˲˲˲$&˱˱˱/a˲˲˲')
// plain www links without https?:// prefix
let re2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim
html = html.replace(re2, '$1˱˱˱a href=ˠˠˠhttp://$2ˠˠˠ target=ˠˠˠ_blankˠˠˠ rel=ˠˠˠnoopenerˠˠˠ˲˲˲$2˱˱˱/a˲˲˲')
// escape to HTML & convert <>" back
html = html
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/˱˱˱/g, '<')
.replace(/˲˲˲/g, '>')
.replace(/ˠˠˠ/g, '"')
return html
},
messageDate: function(d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
}
}
}
</script>
<template>
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" style="overflow-y: scroll;">
<div v-if="message" id="message-view" class="mh-100" style="overflow-y: scroll;">
<div class="row w-100">
<div class="col-md">
<table class="messageHeaders">
<tbody>
<tr>
<th class="small">From</th>
<tr class="small">
<th>From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address" class="small">
&lt;{{ message.From.Address }}&gt;
</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
@@ -269,199 +129,100 @@ export default {
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
<span v-if="message.To" v-for="(t, i) in message.To">
<template v-if="i > 0">, </template>
<span class="text-nowrap">{{ t.Name + " &lt;" + t.Address + "&gt;" }}</span>
<span class="text-nowrap">{{ t.Name + " <" + t.Address +">" }}</span>
</span>
<span v-else class="text-body-secondary">[Undisclosed recipients]</span>
<span v-else>Undisclosed recipients</span>
</td>
</tr>
<tr v-if="message.Cc && message.Cc.length" class="small">
<th>Cc</th>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
</td>
</tr>
<tr v-if="message.Bcc && message.Bcc.length" class="small">
<th>Bcc</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }}
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
<th class="text-nowrap">Reply-To</th>
<td class="privacy text-body-secondary">
<span v-for="(t, i) in message.ReplyTo">
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " &lt;" + t.Address + "&gt;" }} </span>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.ReturnPath && message.ReturnPath != message.From.Address" class="small">
<th class="text-nowrap">Return-Path</th>
<td class="privacy text-body-secondary">&lt;{{ message.ReturnPath }}&gt;</td>
</tr>
<tr>
<th class="small">Subject</th>
<td>
<strong v-if="message.Subject != ''">{{ message.Subject }}</strong>
<small class="text-body-secondary" v-else>[ no subject ]</small>
</td>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
<tr class="d-md-none small">
<tr class="d-md-none">
<th class="small">Date</th>
<td>{{ messageDate(message.Date) }}</td>
</tr>
<tr class="small" v-if="showTags">
<th>Tags</th>
<td>
<select class="form-select small tag-selector" v-model="messageTags" multiple
data-full-width="false" data-suggestions-threshold="1" data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Add tags..."
data-badge-style="secondary" data-regex="^([a-zA-Z0-9\-\ \_]){3,}$"
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allTags" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Please select a valid tag.</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-auto d-none d-md-block text-end mt-md-3">
<div class="mt-2 mt-md-0" v-if="allAttachments(message)">
<span class="badge rounded-pill text-bg-secondary p-2">
<div class="col-md-auto text-md-end mt-md-3">
<p class="text-muted small d-none d-md-block"><small>{{ messageDate(message.Date) }}</small></p>
<div class="dropdown mt-2" v-if="allAttachments(message)">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Attachment<span v-if="allAttachments(message).length > 1">s</span>
({{ allAttachments(message).length }})
</span>
</button>
<ul class="dropdown-menu">
<li v-for="part in allAttachments(message)">
<a :href="'api/'+message.ID+'/part/'+part.PartID" type="button"
class="dropdown-item" target="_blank">
<i class="bi" :class="attachmentIcon(part)"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
<small class="text-muted ms-2">{{ getFileSize(part.Size) }}</small>
</a>
</li>
</ul>
</div>
</div>
</div>
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button"
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" ref="navhtml"
v-on:click="resizeIFrames()">
HTML
</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source"
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML">
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab" data-bs-target="#nav-plain-text"
type="button" role="tab" aria-controls="nav-plain-text" aria-selected="false"
:class="message.HTML == '' ? 'show' : ''">
Text
</button>
<button class="nav-link" id="nav-headers-tab" data-bs-toggle="tab" data-bs-target="#nav-headers"
type="button" role="tab" aria-controls="nav-headers" aria-selected="false">
<span class="d-sm-none">Hdrs</span><span class="d-none d-sm-inline">Headers</span>
</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab" data-bs-target="#nav-raw" type="button"
role="tab" aria-controls="nav-raw" aria-selected="false">
Raw
</button>
<div class="dropdown d-lg-none">
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Checks
</button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
</li>
<li>
<button class="dropdown-item" id="nav-link-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
aria-selected="false">
Link Check
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
<small>{{ formatNumber(linkCheckErrors) }}</small>
</span>
</button>
</li>
</ul>
</div>
<button class="d-none d-lg-inline-block nav-link position-relative" id="nav-html-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
<button class="d-none d-lg-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
aria-selected="false">
Link Check
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
<small>{{ formatNumber(linkCheckErrors) }}</small>
</span>
</button>
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
<template v-for="vals, key in responsiveSizes">
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
v-on:click="scaleHTMLPreview = key">
<i class="bi" :class="'bi-' + key"></i>
</button>
</template>
</div>
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab"
data-bs-target="#nav-html" type="button" role="tab" aria-controls="nav-html"
aria-selected="true" v-if="message.HTML">HTML</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
aria-selected="false" v-if="message.HTMLSource">HTML Source</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab"
data-bs-target="#nav-plain-text" type="button" role="tab" aria-controls="nav-plain-text"
aria-selected="false" :class="message.HTML == '' ? 'show':''">Text</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab"
data-bs-target="#nav-raw" type="button" role="tab" aria-controls="nav-raw"
aria-selected="false">Raw</button>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizeHTML(message.HTML)"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%;">
</iframe>
</div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel"
aria-labelledby="nav-html-source-tab" tabindex="0" v-if="message.HTMLSource">
<pre><code class="language-html">{{ message.HTMLSource }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0"
:class="message.HTML == '' ? 'show' : ''">
<div class="text-view" v-html="textToHTML(message.Text)"></div>
<Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel"
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if="loadHeaders" :message="message"></Headers>
</div>
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab" tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="initRawIframe" frameborder="0"
style="width: 100%; height: 300px"></iframe>
</div>
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab"
tabindex="0">
<HTMLCheck v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
</div>
</div>

View File

@@ -1,670 +0,0 @@
<script>
import axios from 'axios'
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
import commonMixins from '../mixins.js'
import { Tooltip } from 'bootstrap'
export default {
props: {
message: Object,
},
components: {
Donut,
},
emits: ["setHtmlScore", "setBadgeStyle"],
mixins: [commonMixins],
data() {
return {
error: false,
enabled: true,
check: false,
platforms: [],
allPlatforms: {
"windows": "Windows",
"windows-mail": "Windows Mail",
"outlook-com": "Outlook.com",
"macos": "macOS",
"ios": "iOS",
"android": "Android",
"desktop-webmail": "Desktop Webmail",
"mobile-webmail": "Mobile Webmail",
},
}
},
mounted() {
this.enabled = !localStorage.getItem('htmlCheckDisabled')
this.loadConfig()
this.doCheck()
},
computed: {
summary: function () {
let self = this
if (!this.enabled || !this.check) {
return false
}
let result = {
Warnings: [],
Total: {
Nodes: this.check.Total.Nodes
}
}
for (let i = 0; i < this.check.Warnings.length; i++) {
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
// for <script> test
if (o.Results.length == 0) {
result.Warnings.push(o)
continue
}
// filter by enabled platforms
let results = o.Results.filter(function (w) {
return self.platforms.indexOf(w.Platform) != -1
})
if (results.length == 0) {
continue
}
// recalculate the percentages
let y = 0, p = 0, n = 0
results.forEach(function (r) {
if (r.Support == "yes") {
y++
} else if (r.Support == "partial") {
p++
} else {
n++
}
})
let total = y + p + n
o.Results = results
o.Score = {
Found: o.Score.Found,
Supported: y / total * 100,
Partial: p / total * 100,
Unsupported: n / total * 100
}
result.Warnings.push(o)
}
let maxPartial = 0, maxUnsupported = 0
result.Warnings.forEach(function (w) {
let scoreWeight = 1
if (w.Score.Found < result.Total.Nodes) {
// each error is weighted based on the number of occurrences vs: the total message nodes
scoreWeight = w.Score.Found / result.Total.Nodes
}
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
// are actually used in the HTML, and including things like bootstrap styles completely throws
// off the calculation as these dominate.
if (self.isPseudoClassOrAtRule(w.Title)) {
scoreWeight = 0.05
w.PseudoClassOrAtRule = true
}
let scorePartial = w.Score.Partial * scoreWeight
let scoreUnsupported = w.Score.Unsupported * scoreWeight
if (scorePartial > maxPartial) {
maxPartial = scorePartial
}
if (scoreUnsupported > maxUnsupported) {
maxUnsupported = scoreUnsupported
}
})
// sort warnings by final score
result.Warnings.sort(function (a, b) {
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
if (self.isPseudoClassOrAtRule(a.Title)) {
aWeight = 0.05
}
if (self.isPseudoClassOrAtRule(b.Title)) {
bWeight = 0.05
}
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
})
result.Total.Supported = 100 - maxPartial - maxUnsupported
result.Total.Partial = maxPartial
result.Total.Unsupported = maxUnsupported
this.$emit('setHtmlScore', result.Total.Supported)
return result
},
graphSections: function () {
let s = Math.round(this.summary.Total.Supported)
let p = Math.round(this.summary.Total.Partial)
let u = 100 - s - p
return [
{
label: this.round2dm(this.summary.Total.Supported) + '% supported',
value: s,
color: '#198754'
},
{
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
value: p,
color: '#ffc107'
},
{
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
value: u,
color: '#dc3545'
}
]
},
// colors depend on both varying unsupported & partially unsupported percentages
scoreColor: function () {
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
this.$emit('setBadgeStyle', 'bg-success')
return 'text-success'
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
this.$emit('setBadgeStyle', 'bg-warning text-primary')
return 'text-warning'
}
this.$emit('setBadgeStyle', 'bg-danger')
return 'text-danger'
}
},
watch: {
message: {
handler() {
this.$emit('setHtmlScore', false)
this.doCheck()
},
deep: true
},
platforms(v) {
localStorage.setItem('html-check-platforms', JSON.stringify(v))
},
enabled(v) {
if (!v) {
localStorage.setItem('htmlCheckDisabled', true)
this.$emit('setHtmlScore', false)
} else {
localStorage.removeItem('htmlCheckDisabled')
this.doCheck()
}
}
},
methods: {
doCheck: function () {
if (!this.enabled) {
return
}
this.check = false
if (this.message.HTML == "") {
return
}
let self = this
// ignore any error, do not show loader
axios.get('api/v1/message/' + self.message.ID + '/html-check', null)
.then(function (result) {
self.check = result.data
self.error = false
// set tooltips
window.setTimeout(function () {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
}, 500)
})
.catch(function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
} else {
self.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
}
})
},
loadConfig: function () {
let platforms = localStorage.getItem('html-check-platforms')
if (platforms) {
try {
this.platforms = JSON.parse(platforms)
} catch (e) {
}
}
// set all options
if (this.platforms.length == 0) {
this.platforms = Object.keys(this.allPlatforms)
}
},
// return a platform's families (email clients)
families: function (k) {
if (this.check.Platforms[k]) {
return this.check.Platforms[k]
}
return []
},
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
isPseudoClassOrAtRule: function (t) {
return t.match(/^(:|@)/)
},
round: function (v) {
return Math.round(v)
},
round2dm: function (v) {
return Math.round(v * 100) / 100
},
scrollToWarnings: function () {
if (!this.$refs.warnings) {
return
}
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
},
}
}
</script>
<template>
<template v-if="error">
<p>HTML check failed to load:</p>
<div class="alert alert-warning">
{{ error }}
</div>
</template>
<template v-if="!enabled">
<h2 class="h4 text-secondary">HTML check is currently disabled</h2>
<p class="text-secondary">
This feature is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck">
<label class="form-check-label" for="inlineEnableHTMLCheck">
Enable HTML check
</label>
</div>
</template>
<template v-if="summary">
<div class="mt-5 mb-3">
<div class="row w-100">
<div class="col-md-8">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
@section-click="scrollToWarnings">
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ round2dm(summary.Total.Supported) }}%
</h2>
<div class="text-body">
support
</div>
<template #legend>
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
<span class="text-nowrap">
<i class="bi bi-circle-fill text-success"></i>
{{ round2dm(summary.Total.Supported) }}% supported
</span> &nbsp;
<span class="text-nowrap">
<i class="bi bi-circle-fill text-warning"></i>
{{ round2dm(summary.Total.Partial) }}% partially supported
</span> &nbsp;
<span class="text-nowrap">
<i class="bi bi-circle-fill text-danger"></i>
{{ round2dm(summary.Total.Unsupported) }}% not supported
</span>
</p>
<p class="small text-secondary">
calculated from {{ formatNumber(check.Total.Tests) }} tests
</p>
</template>
</Donut>
<div class="input-group justify-content-center mb-3">
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
data-bs-target="#AboutHTMLCheckResults">
<i class="bi bi-info-circle-fill"></i>
Help
</button>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions">
<i class="bi bi-gear-fill"></i>
Settings
</button>
</div>
</div>
<div class="col-md">
<h2 class="h5 mb-3">Tested platforms:</h2>
<div class="form-check form-switch" v-for="p, k in allPlatforms">
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
:aria-label="p" :id="'Check_' + k">
<label class="form-check-label" :for="'Check_' + k"
:class="platforms.indexOf(k) !== -1 ? '' : 'text-secondary'" :title="families(k).join(', ')"
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
{{ p }}
</label>
</div>
</div>
</div>
</div>
<template v-if="summary.Warnings.length">
<h4 ref="warnings" class="h5 mt-4">
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
</h4>
<div class="accordion" id="warnings">
<div class="accordion-item" v-for="warning in summary.Warnings">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
<div class="row w-100 w-lg-75">
<div class="col-sm">
{{ warning.Title }}
<span class="ms-2 small badge text-bg-secondary" title="Test category">
{{ warning.Category }}
</span>
<span class="ms-2 small badge text-bg-light"
title="The number of times this was detected">
x {{ warning.Score.Found }}
</span>
</div>
<div class="col-sm mt-2 mt-sm-0">
<div class="progress-stacked">
<div class="progress" role="progressbar" aria-label="Supported"
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
<div class="progress-bar bg-success">
{{ round(warning.Score.Supported) + '%' }}
</div>
</div>
<div class="progress" role="progressbar" aria-label="Partial"
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
<div class="progress-bar progress-bar-striped bg-warning text-dark">
{{ round(warning.Score.Partial) + '%' }}
</div>
</div>
<div class="progress" role="progressbar" aria-label="No"
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
<div class="progress-bar bg-danger">
{{ round(warning.Score.Unsupported) + '%' }}
</div>
</div>
</div>
</div>
</div>
</button>
</h2>
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
<div class="accordion-body">
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
<i class="bi bi-info-circle me-2"></i>
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
propert<template v-if="warning.Score.Found === 1">y</template><template
v-else>ies</template> in the CSS styles, but unable to test if used or not.
</span>
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
</p>
<template v-if="warning.Results.length">
<h3 class="h6">Clients with partial or no support:</h3>
<p>
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
<i class="bi bi-circle-fill"
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
{{ warning.Name }}
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
title="See notes">
{{ warning.NoteNumber }}
</span>
</small>
</p>
</template>
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
<h3 class="h6">Notes:</h3>
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
<div class="col-auto pe-0">
<span class="badge text-bg-secondary">
{{ i }}
</span>
</div>
<div class="col" v-html="n"></div>
</div>
</div>
<p class="small mt-3 mb-0" v-if="warning.URL">
<a :href="warning.URL" target="_blank">Online reference</a>
</p>
</div>
</div>
</div>
</div>
<p class="text-center text-secondary small mt-4">
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
</p>
</template>
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
HTML check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="HTMLCheckAboutAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
What is HTML check?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
The support for HTML/CSS messages varies greatly across email clients. HTML check
attempts to calculate the overall support for your email for all selected platforms
to give you some idea of the general compatibility of your HTML email.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
How does it work?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
<p>
Internally the original HTML message is run against
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
(except for <code>&lt;script&gt;</code>) correspond to a test on
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
final score is calculated using the available compatibility data.
</p>
<p>
CSS support is very difficult to programmatically test, especially if a message
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
to matching HTML elements. This gives Mailpit fairly accurate results.
</p>
<p>
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
<code>:hover</code> etc cannot be inlined however, so these are searched for
within CSS blocks. This method is not accurate as Mailpit does not know how many
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
to affect the score. An example of this would be any email linking to the full
bootstrap CSS which contains dozens of unused attributes.
</p>
<p>
All warnings are displayed with their respective support, including any specific
notes, and it is up to you to decide what you do with that information and how
badly it may impact your message.
</p>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
Is the final score accurate?
</button>
</h2>
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
<p>
There are many ways to define "accurate", and how one should calculate the
compatibility score of an email. There is also no way to programmatically
determine the relevance of a single test to the entire email.
</p>
<p>
For each test, Mailpit calculates both the unsupported & partially-supported
percentages in relation to the number of matches against the total number of
nodes (elements) in the HTML. The maximum unsupported and partially-supported
weighted scores are then used for the final score (ie: worst case scenario).
</p>
<p>
To try explain this logic in very simple terms: Assuming a
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported in
any email client), and a <code>&lt;p&gt;</code> node has 100% pass (supported).
</p>
<ul>
<li>
An email containing just a single <code>&lt;script&gt;</code>: the final
score is 0% supported.
</li>
<li>
An email containing just a <code>&lt;script&gt;</code> and a
<code>&lt;p&gt;</code>: the final score is 50% supported.
</li>
<li>
An email containing just a <code>&lt;script&gt;</code> and two
<code>&lt;p&gt;</code>: the final score is 66.67% supported.
</li>
</ul>
<p>
Mailpit will sort the warnings according to their weighted unsupported scores.
</p>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
What about invalid HTML?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
HTML check does not detect if the original HTML is valid. In order to detect applied
styles to every node, the HTML email is run through a parser which is very good at
turning invalid input into valid output. It is what it is...
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>
<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="HTMLCheckOptionsLabel">HTML check options</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
HTML check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled"
id="HTMLCheckSwitch">
<label class="form-check-label" for="HTMLCheckSwitch">
<template v-if="enabled">HTML check is enabled in the web UI</template>
<template v-else>HTML check is disabled in the web UI</template>
</label>
</div>
<p class="mt-4 small text-center text-secondary">
HTML check can be globally disabled with <code>--disable-html-check</code><br>
Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,398 +0,0 @@
<script>
import axios from 'axios'
import commonMixins from '../mixins.js'
export default {
props: {
message: Object,
},
emits: ["setLinkErrors"],
mixins: [commonMixins],
data() {
return {
error: false,
autoScan: false,
followRedirects: false,
check: false,
loaded: false,
loading: false,
}
},
created() {
this.autoScan = localStorage.getItem('LinkCheckAutoScan')
this.followRedirects = localStorage.getItem('LinkCheckFollowRedirects')
},
mounted() {
this.loaded = true
if (this.autoScan) {
this.doCheck()
}
},
watch: {
autoScan(v) {
if (!this.loaded) {
return
}
if (v) {
localStorage.setItem('LinkCheckAutoScan', true)
if (!this.check) {
this.doCheck()
}
} else {
localStorage.removeItem('LinkCheckAutoScan')
}
},
followRedirects(v) {
if (!this.loaded) {
return
}
if (v) {
localStorage.setItem('LinkCheckFollowRedirects', true)
} else {
localStorage.removeItem('LinkCheckFollowRedirects')
}
if (this.check) {
this.doCheck()
}
}
},
computed: {
groupedStatuses: function () {
let results = {}
if (!this.check) {
return results
}
// group by status
this.check.Links.forEach(function (r) {
if (!results[r.StatusCode]) {
let css = ""
if (r.StatusCode >= 400 || r.StatusCode === 0) {
css = "text-danger"
} else if (r.StatusCode >= 300) {
css = "text-info"
}
if (r.StatusCode === 0) {
r.Status = 'Cannot connect to server'
}
results[r.StatusCode] = {
StatusCode: r.StatusCode,
Status: r.Status,
Class: css,
URLS: []
}
}
results[r.StatusCode].URLS.push(r.URL)
})
let newArr = []
for (const i in results) {
newArr.push(results[i])
}
// sort statuses
let sorted = newArr.sort((a, b) => {
if (a.StatusCode === 0) {
return false
}
return a.StatusCode < b.StatusCode
})
return sorted
}
},
methods: {
doCheck: function () {
this.check = false
let self = this
this.loading = true
let uri = 'api/v1/message/' + self.message.ID + '/link-check'
if (this.followRedirects) {
uri += '?follow=true'
}
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {
self.check = result.data
self.error = false
self.$emit('setLinkErrors', result.data.Errors)
})
.catch(function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
} else {
self.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
}
})
.then(function (result) {
// always run
self.loading = false
})
},
}
}
</script>
<template>
<div class="pe-3">
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<template v-if="!check">
Link check
</template>
<template v-else>
<template v-if="check.Links.length">
Scanned {{ formatNumber(check.Links.length) }}
link<template v-if="check.Links.length != 1">s</template>
</template>
<template v-else>
No links detected
</template>
</template>
</h4>
</div>
<div class="col-auto">
<div class="input-group">
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
data-bs-target="#AboutLinkCheckResults">
<i class="bi bi-info-circle-fill"></i>
Help
</button>
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#LinkCheckOptions">
<i class="bi bi-gear-fill"></i>
Settings
</button>
</div>
</div>
</div>
<div v-if="!check">
<p class="text-secondary">
Link check scans your email text &amp; HTML for unique links, testing the response status codes.
This includes links to images and remote CSS stylesheets.
</p>
<p class="text-center my-5">
<button v-if="!check" class="btn btn-primary btn-lg" @click="doCheck()" :disabled="loading">
<template v-if="loading">
Checking links
<div class="ms-1 spinner-border spinner-border-sm text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</template>
<template v-else>
<i class="bi bi-check-square me-2"></i>
Check message links
</template>
</button>
</p>
</div>
<div v-else v-for="s, k in groupedStatuses">
<div class="card mb-3">
<div class="card-header h4" :class="s.Class">
Status {{ s.StatusCode }}
<small v-if="s.Status != ''" class="ms-2 small text-secondary">({{ s.Status }})</small>
</div>
<ul class="list-group list-group-flush">
<li v-for="u in s.URLS" class="list-group-item">
<a :href="u" target="_blank" class="no-icon">{{ u }}</a>
</li>
</ul>
</div>
</div>
<template v-if="error">
<p>Link check failed to load:</p>
<div class="alert alert-warning">
{{ error }}
</div>
</template>
</div>
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="LinkCheckOptionsLabel">Link check options</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<h6 class="mt-4">Follow HTTP redirects (status 301 & 302)</h6>
<div class="form-check form-switch mb-4">
<input class="form-check-input" type="checkbox" role="switch" v-model="followRedirects"
id="LinkCheckFollowRedirectsSwitch">
<label class="form-check-label" for="LinkCheckFollowRedirectsSwitch">
<template v-if="followRedirects">Following HTTP redirects</template>
<template v-else>Not following HTTP redirects</template>
</label>
</div>
<h6 class="mt-4">Automatic link checking</h6>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" v-model="autoScan"
id="LinkCheckAutoCheckSwitch">
<label class="form-check-label" for="LinkCheckAutoCheckSwitch">
<template v-if="autoScan">Automatic link checking is enabled</template>
<template v-else>Automatic link checking is disabled</template>
</label>
<div class="form-text">
Note: Enabling auto checking will scan every link & image every time a message is opened.
Only enable this if you understand the potential risks &amp; consequences.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="AboutLinkCheckResults" tabindex="-1" aria-labelledby="AboutLinkCheckResultsLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AboutLinkCheckResultsLabel">About Link check</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Link check is currently in beta. Constructive feedback is welcome via
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
</p>
<div class="accordion" id="LinkCheckAboutAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
What is Link check?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
Link check scans your message HTML and text for all unique links, images and linked
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a time, to
test whether the link/image/stylesheet exists.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
What are "301" and "302" links?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
These are links that redirect you to another URL, for example newsletters
often use redirect links to track user clicks.
</p>
<p>
By default Link check will not follow these links, however you can turn this on via
the settings and Link check will "follow" those redirects.
</p>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
Why are some links returning an error but work in my browser?
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>This may be due to various reasons, for instance:</p>
<ul>
<li>The Mailpit server cannot resolve (DNS) the hostname of the URL.</li>
<li>Mailpit is not allowed to access the URL.</li>
<li>
The webserver is blocking requests that don't come from authenticated web
browsers.
</li>
<li>The webserver or doesn't allow HTTP <code>HEAD</code> requests. </li>
</ul>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
What are the risks of running Link check automatically?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
Depending on the type of messages you are testing, opening all links on all messages
may have undesired consequences:
</p>
<ul>
<li>If the message contains tracking links this may reveal your identity.</li>
<li>
If the message contains unsubscribe links, Link check could unintentionally
unsubscribe you.
</li>
<li>
To speed up the checking process, Link check will attempt 5 URLs at a time. This
could lead to temporary heady load on the remote server.
</li>
</ul>
<p>
Unless you know what messages you receive, it is advised to only run the Link check
manually.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,116 +0,0 @@
<script>
import Tags from "bootstrap5-tags"
import commonMixins from '../mixins.js'
export default {
props: {
message: Object,
uiConfig: Object,
releaseAddresses: Array
},
data() {
return {
addresses: []
}
},
mixins: [commonMixins],
mounted() {
this.addresses = JSON.parse(JSON.stringify(this.releaseAddresses))
this.$nextTick(function () {
Tags.init("select[multiple]")
})
},
methods: {
releaseMessage: function () {
let self = this
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false
}
let data = {
to: self.addresses
}
self.post('api/v1/message/' + self.message.ID + '/release', data, function (response) {
self.modal("ReleaseModal").hide()
})
}, 100)
}
}
}
</script>
<template>
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From.Address">
</div>
</div>
<div class="row">
<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label>
<div class="col-sm-10">
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
:value="message.Subject">
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
<div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Enter email addresses..."
data-add-on-blur="true" data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|">
<option value="">Enter email addresses...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in releaseAddresses" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div class="form-text text-center" v-if="uiConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ uiConfig.MessageRelay.RecipientAllowlist }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="uiConfig.MessageRelay.ReturnPath != ''">{{ uiConfig.MessageRelay.ReturnPath }}</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script>
import commonMixins from '../mixins.js'
export default {
props: {
message: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="card mt-4">
<div class="card-body text-body-secondary small">
<p class="card-text">
<b>Message date:</b><br>
<small>{{ messageDate(message.Date) }}</small>
</p>
<p class="card-text">
<b>Size:</b> {{ getFileSize(message.Size) }}
</p>
<p class="card-text" v-if="allAttachments(message).length">
<b>Attachments:</b> {{ allAttachments(message).length }}
</p>
</div>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script>
import { Toast } from 'bootstrap'
export default {
props: {
message: Object
},
mounted() {
let self = this
let el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.$emit("clearMessageToast")
})
let b = Toast.getOrCreateInstance(el)
b.show()
}
}
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto"><a :href="'#' + message.ID">New message</a></strong>
<small class="text-body-secondary">now</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<a :href="'#' + message.ID" class="d-block text-truncate text-body-secondary">
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
<template v-else>[ no subject ]</template>
</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,123 +0,0 @@
<script>
export default {
data() {
return {
theme: 'auto',
icon: '#circle-half',
icons: {
'auto': '#circle-half',
'light': '#sun-fill',
'dark': '#moon-stars-fill'
}
}
},
mounted() {
this.setTheme(this.getPreferredTheme())
},
methods: {
getStoredTheme: function () {
let theme = localStorage.getItem('theme')
if (!theme) {
theme = 'auto'
}
return theme
},
setStoredTheme: function (theme) {
localStorage.setItem('theme', theme)
this.setTheme(theme)
},
getPreferredTheme: function () {
const storedTheme = this.getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
},
setTheme: function (theme) {
this.icon = this.icons[theme]
this.theme = theme
if (
theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
}
}
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="bootstrap" viewBox="0 0 512 408" fill="currentcolor">
<path
d="M106.342 0c-29.214 0-50.827 25.58-49.86 53.32.927 26.647-.278 61.165-8.966 89.31C38.802 170.862 24.07 188.707 0 191v26c24.069 2.293 38.802 20.138 47.516 48.37 8.688 28.145 9.893 62.663 8.965 89.311C55.515 382.42 77.128 408 106.342 408h299.353c29.214 0 50.827-25.58 49.861-53.319-.928-26.648.277-61.166 8.964-89.311 8.715-28.232 23.411-46.077 47.48-48.37v-26c-24.069-2.293-38.765-20.138-47.48-48.37-8.687-28.145-9.892-62.663-8.964-89.31C456.522 25.58 434.909 0 405.695 0H106.342zm236.559 251.102c0 38.197-28.501 61.355-75.798 61.355h-87.202a2 2 0 01-2-2v-213a2 2 0 012-2h86.74c39.439 0 65.322 21.354 65.322 54.138 0 23.008-17.409 43.61-39.594 47.219v1.203c30.196 3.309 50.532 24.212 50.532 53.085zm-84.58-128.125h-45.91v64.814h38.669c29.888 0 46.373-12.03 46.373-33.535 0-20.151-14.174-31.279-39.132-31.279zm-45.91 90.53v71.431h47.605c31.12 0 47.605-12.482 47.605-35.941 0-23.46-16.947-35.49-49.608-35.49h-45.602z" />
</symbol>
<symbol id="check2" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</symbol>
<symbol id="circle-half" viewBox="0 0 16 16" fill="currentcolor">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
</symbol>
<symbol id="moon-stars-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
<path
d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
</symbol>
<symbol id="sun-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</symbol>
</svg>
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em">
<use :href="icon"></use>
</svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#sun-fill"></use>
</svg>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#moon-stars-fill"></use>
</svg>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
Auto
</button>
</li>
</ul>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Mailpit API v1 documentation</title>
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="../../favicon.svg">
<script src="../../dist/docs.js"></script>
</head>
<body>
<rapi-doc id="thedoc" spec-url="swagger.json" theme="light" layout="column" render-style="read" load-fonts="false"
regular-font="system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'"
mono-font="Courier New, Courier, System, fixed-width" font-size="large" allow-spec-url-load="false"
allow-spec-file-load="false" allow-server-selection="false" allow-search="false" allow-advanced-search="false"
show-curl-before-try="true" bg-color="#ffffff" nav-bg-color="#e3e8ec" nav-text-color="#212529"
nav-hover-bg-color="#fff" header-color="#2c3e50" primary-color="#2c3e50" text-color="#212529">
<div slot="header">Mailpit API v1 documentation</div>
<a target='_blank' href="../../" slot="logo">
<img src="../../mailpit.svg" width="40" alt="Mailpit" />
</a>
</rapi-doc>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="460"
viewBox="0 0 132.292 121.708"
version="1.1"
id="svg6"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<path
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
fill-opacity=".941"
fill="#2d4a5f"
id="path2"
style="fill:#415066;fill-opacity:1" />
<path
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
fill="#00b786"
id="path4" />
</svg>

Before

Width:  |  Height:  |  Size: 898 B

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="favicon.svg">
<link rel="icon" href="mailpit.svg">
<title>Mailpit</title>
<link rel=stylesheet href="dist/app.css">
</head>

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,22 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="460"
viewBox="0 0 132.292 121.708"
viewBox="0 0 132.29167 121.70833"
version="1.1"
id="svg6"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="mailpit.svg"
inkscape:export-filename="/home/ralph/bitmap.png"
inkscape:export-xdpi="176.09"
inkscape:export-ydpi="176.09">
<defs
id="defs10" />
<path
d="M12.321 0l53.861 53.918L120.365 0zM5.155 9.025l60.842 59.673 61.211-59.489-.185 36.835L66.921 70.54l15.164 12.616-8.137 5.986-41.609.184c-4.838-.022-25.877-18.34-27.185-41.255z"
fill-opacity=".941"
fill="#2d4a5f"
id="path2"
style="fill:#ffffff;fill-opacity:1" />
<path
d="M78.385 72.049l53.907-21.679-8.031 57.318-11.845-9.132c-21.727 23.171-45.255 26.289-67.997 20.837S12.281 98.39 5.155 83.8-.67 53.116 2.843 38.769c1.13 10.511-1.313 16.316 6.38 33.612 6.31 11.399 14.413 20.417 25.89 24.956 13.9 6.195 32.247 3.357 41.701-3.039l14.24-12.156z"
fill="#00b786"
id="path4" />
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="90.98717"
inkscape:cy="229.51456"
inkscape:document-units="mm"
inkscape:current-layer="layer2"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
inkscape:window-width="1548"
inkscape:window-height="838"
inkscape:window-x="52"
inkscape:window-y="25"
inkscape:window-maximized="1">
<sodipodi:guide
position="39.014182,62.44412"
orientation="0,1"
id="guide4529"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
style="display:inline"
transform="translate(-55.479864,-26.541592)">
<g
id="g4547"
transform="matrix(1.9570423,0,0,1.9490788,-53.096581,-140.70068)"
style="opacity:1">
<path
sodipodi:nodetypes="cccc"
inkscape:connector-curvature="0"
id="path4534"
d="M 61.775483,85.805801 89.296873,113.46893 116.98363,85.8058 Z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
id="path4540"
d="m 58.113837,90.436008 31.088544,30.616072 31.277529,-30.521576 -0.0945,18.898806 -30.71057,12.56771 7.748511,6.47285 -4.157737,3.07105 -21.26116,0.0945 c -2.471939,-0.0114 -13.222442,-9.40933 -13.890627,-21.16666 z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccczzcccccc"
inkscape:connector-curvature="0"
id="path4542"
d="m 95.532643,122.7713 27.544977,-11.12272 -4.10354,29.40775 -6.05271,-4.68532 c -11.10189,11.88809 -23.124233,13.48775 -34.745034,10.69078 -11.620801,-2.79697 -16.420919,-10.7759 -20.062499,-18.2612 -3.64158,-7.4853 -2.976265,-15.74301 -1.181174,-23.10379 0.577547,5.393 -0.671158,8.37123 3.260045,17.24516 3.224283,5.84857 7.36483,10.47545 13.229166,12.80395 7.102803,3.17859 16.477397,1.7222 21.308409,-1.55916 l 7.276037,-6.2366 z"
style="fill:#00b786;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -5,11 +5,11 @@
package websockets
import (
"log"
"net/http"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/websocket"
)
@@ -117,7 +117,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Log().Error(err)
log.Println(err)
return
}

View File

@@ -7,7 +7,8 @@ package websockets
import (
"encoding/json"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
)
// Hub maintains the set of active clients and broadcasts messages to the
@@ -26,12 +27,6 @@ type Hub struct {
unregister chan *Client
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}
// NewHub returns a new hub configuration
func NewHub() *Hub {
return &Hub{
@@ -73,7 +68,7 @@ func Broadcast(t string, msg interface{}) {
return
}
w := WebsocketNotification{}
w := data.WebsocketNotification{}
w.Type = t
w.Data = msg
b, err := json.Marshal(w)

81
smtpd/smtpd.go Normal file
View File

@@ -0,0 +1,81 @@
package smtpd
import (
"bytes"
"net"
"net/mail"
"regexp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
"github.com/mhale/smtpd"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("error parsing message: %s", err.Error())
return err
}
if _, err := storage.Store(data); err != nil {
// Value with size 4800709 exceeded 1048576 limit
re := regexp.MustCompile(`(Value with size \d+ exceeded \d+ limit)`)
tooLarge := re.FindStringSubmatch(err.Error())
if len(tooLarge) > 0 {
logger.Log().Errorf("[db] error storing message: %s", tooLarge[0])
} else {
logger.Log().Errorf("[db] error storing message")
logger.Log().Errorf(err.Error())
}
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtp] received mail from %s for %s with subject %s", from, to[0], subject)
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
return config.SMTPAuth.Match(string(username), string(password)), nil
}
// Listen starts the SMTPD server
func Listen() error {
if config.SMTPSSLCert != "" {
logger.Log().Info("[smtp] enabling TLS")
}
if config.SMTPAuthFile != "" {
logger.Log().Info("[smtp] enabling authentication")
}
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthFile != "" {
srv.AuthHandler = authHandler
srv.AuthRequired = true
}
if config.SMTPSSLCert != "" {
err := srv.ConfigureTLS(config.SMTPSSLCert, config.SMTPSSLKey)
if err != nil {
return err
}
}
return srv.ListenAndServe()
}

View File

@@ -1,4 +1,3 @@
// Package storage handles all database actions
package storage
import (
@@ -13,15 +12,16 @@ import (
"os/signal"
"path"
"path/filepath"
"sort"
"regexp"
"strings"
"syscall"
"time"
"github.com/GuiaBolso/darwin"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
@@ -65,57 +65,20 @@ var (
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
},
{
Version: 1.1,
Description: "Create tags column",
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.2,
Description: "Creating new mailbox format",
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO mailboxtmp
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM mailbox;
DROP TABLE IF EXISTS mailbox;
ALTER TABLE mailboxtmp RENAME TO mailbox;
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
}
)
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Created time.Time
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Size int
Inline int
Attachments int
}
// InitDB will initialise the database
@@ -132,8 +95,6 @@ func InitDB() error {
p = filepath.Clean(p)
}
config.DataFile = p
logger.Log().Debugf("[db] opening database %s", p)
var err error
@@ -174,8 +135,6 @@ func InitDB() error {
// auto-prune & delete
go dbCron()
go dataMigrations()
return nil
}
@@ -206,14 +165,14 @@ func Close() {
// Store will save an email to the database tables
func Store(body []byte) (string, error) {
// Parse message body with enmime
// Parse message body with enmime.
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
if err != nil {
logger.Log().Warningf("[db] %s", err.Error())
return "", nil
}
from := &mail.Address{}
var from *mail.Address
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
@@ -221,22 +180,16 @@ func Store(body []byte) (string, error) {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
obj := DBMailSummary{
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
}
created := time.Now()
// use message date instead of created date
if config.UseMessageDates {
if mDate, err := env.Date(); err == nil {
created = mDate
}
Created: time.Now(),
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
Subject: env.GetHeader("Subject"),
Size: len(body),
Inline: len(env.Inlines),
Attachments: len(env.Attachments),
}
// generate the search text
@@ -245,26 +198,7 @@ func Store(body []byte) (string, error) {
// generate unique ID
id := uuid.NewV4().String()
summaryJSON, err := json.Marshal(obj)
if err != nil {
return "", err
}
// extract tags from body matches based on --tag
tagStr := findTagsInRawMessage(&body)
// extract tags from X-Tags header
headerTags := strings.TrimSpace(env.Root.Header.Get("X-Tags"))
if headerTags != "" {
tagStr += "," + headerTags
}
tagData := uniqueTagsFromString(tagStr)
tagJSON, err := json.Marshal(tagData)
if err != nil {
return "", err
}
b, err := json.Marshal(obj)
// begin a transaction to ensure both the message
// and data are stored successfully
@@ -277,14 +211,8 @@ func Store(body []byte) (string, error) {
// roll back if it fails
defer tx.Rollback()
subject := env.GetHeader("Subject")
size := len(body)
inline := len(env.Inlines)
attachments := len(env.Attachments)
// insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Tags, Read) values(?,?,?,?,?,?,?,?,?,?,0)",
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, string(tagJSON))
// insert summary
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Read) values(?,?,?, 0)", id, string(b), searchText)
if err != nil {
return "", err
}
@@ -300,18 +228,13 @@ func Store(body []byte) (string, error) {
return "", err
}
c := &MessageSummary{}
if err := json.Unmarshal(summaryJSON, c); err != nil {
// return summary
c := &data.Summary{}
if err := json.Unmarshal(b, c); err != nil {
return "", err
}
c.Created = created
c.ID = id
c.MessageID = messageID
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
websockets.Broadcast("new", c)
@@ -322,54 +245,37 @@ func Store(body []byte) (string, error) {
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
func List(start, limit int) ([]data.Summary, error) {
results := []data.Summary{}
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags`).
OrderBy("Created DESC").
Select(`ID, Data, Read`).
OrderBy("Sort DESC").
Limit(limit).
Offset(start)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var summary string
var read int
em := MessageSummary{}
em := data.Summary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags); err != nil {
if err := row.Scan(&id, &summary, &read); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
err := json.Unmarshal([]byte(summary), &em)
if err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
results = append(results, em)
// logger.PrettyPrint(em)
}); err != nil {
return results, err
}
@@ -380,17 +286,12 @@ func List(start, limit int) ([]MessageSummary, error) {
}
// 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:
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
func Search(search string) ([]data.Summary, error) {
results := []data.Summary{}
start := time.Now()
s := strings.ToLower(search)
// add another quote if missing closing quote
@@ -402,76 +303,50 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
p := shellwords.NewParser()
args, err := p.Parse(s)
if err != nil {
return results, nrResults, errors.New("Your search contains invalid characters")
// return errors.New("Your search contains invalid characters")
panic(err)
}
// generate the SQL based on arguments
q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var summary string
var read int
var ignore string
em := MessageSummary{}
em := data.Summary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
if err := row.Scan(&id, &summary, &read, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
err := json.Unmarshal([]byte(summary), &em)
if err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
allResults = append(allResults, em)
results = append(results, em)
}); err != nil {
return results, nrResults, err
return results, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
elapsed := time.Since(tsStart)
elapsed := time.Since(start)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
dbLastAction = time.Now()
return results, err
}
// 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) {
// GetMessage returns a data.Message generated from the mailbox_data collection.
func GetMessage(id string) (*data.Message, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
@@ -492,75 +367,48 @@ func GetMessage(id string) (*Message, error) {
from = &mail.Address{Name: env.GetHeader("From")}
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
date, _ := env.Date()
returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>")
if returnPath == "" && from != nil {
returnPath = from.Address
}
date, err := env.Date()
if err != nil {
// return received datetime when message does not contain a date header
q := sqlf.From("mailbox").
Select(`Created`).
Where(`ID = ?`, id)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
if err := row.Scan(&created); err != nil {
logger.Log().Error(err)
return
}
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(created)
}); err != nil {
logger.Log().Error(err)
}
}
obj := Message{
obj := data.Message{
ID: id,
MessageID: messageID,
Read: true,
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
ReplyTo: addressToSlice(env, "Reply-To"),
ReturnPath: returnPath,
Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id),
Size: len(raw),
Text: env.Text,
HTMLSource: env.HTML,
}
obj.HTML = env.HTML
obj.Inline = []Attachment{}
obj.Attachments = []Attachment{}
html := env.HTML
// strip base tags
var re = regexp.MustCompile(`(?U)<base .*>`)
html = re.ReplaceAllString(html, "")
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
obj.Inline = append(obj.Inline, data.AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, AttachmentSummary(i))
obj.Inline = append(obj.Inline, data.AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, AttachmentSummary(a))
obj.Attachments = append(obj.Attachments, data.AttachmentSummary(a))
}
}
obj.HTML = html
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
@@ -663,7 +511,6 @@ func MarkAllRead() error {
_, err := sqlf.Update("mailbox").
Set("Read", 1).
Where("Read = ?", 0).
ExecAndClose(context.Background(), db)
if err != nil {
return err
@@ -677,29 +524,6 @@ func MarkAllRead() error {
return nil
}
// MarkAllUnread will mark all messages as unread
func MarkAllUnread() error {
var (
start = time.Now()
total = CountRead()
)
_, err := sqlf.Update("mailbox").
Set("Read", 0).
Where("Read = ?", 1).
ExecAndClose(context.Background(), db)
if err != nil {
return err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
@@ -798,56 +622,24 @@ func DeleteAllMessages() error {
dbLastAction = time.Now()
dbDataDeleted = false
websockets.Broadcast("prune", nil)
return err
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
func StatsGet() data.MailboxStats {
var (
start = time.Now()
total = CountTotal()
unread = CountUnread()
)
logger.Log().Debugf("[db] statistics calculated in %s", time.Since(start))
dbLastAction = time.Now()
q := sqlf.From("mailbox").
Select(`DISTINCT Tags`).
Where("Tags != ?", "[]")
var tags = []string{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var tagData string
t := []string{}
if err := row.Scan(&tagData); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tagData), &t); err != nil {
logger.Log().Error(err)
return
}
for _, tag := range t {
if !inArray(tag, tags) {
tags = append(tags, tag)
}
}
}); err != nil {
logger.Log().Error(err)
}
sort.Strings(tags)
return MailboxStats{
return data.MailboxStats{
Total: total,
Unread: unread,
Tags: tags,
}
}
@@ -863,6 +655,7 @@ func CountTotal() int {
}
// CountUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func CountUnread() int {
var total int
@@ -875,19 +668,6 @@ func CountUnread() int {
return total
}
// CountRead returns the number of emails in the database that are read.
func CountRead() int {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("Read = ?", 1)
_ = q.QueryRowAndClose(nil, db)
return total
}
// IsUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func IsUnread(id string) bool {
@@ -902,16 +682,3 @@ func IsUnread(id string) bool {
return unread == 1
}
// MessageIDExists checks whether a Message-ID exists in the DB
func MessageIDExists(id string) bool {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("MessageID = ?", id)
_ = q.QueryRowAndClose(nil, db)
return total != 0
}

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
)
@@ -86,7 +85,7 @@ func TestMimeEmailInserts(t *testing.T) {
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
t.Logf("deleted %d mime emails in %s", testRuns, time.Since(delStart))
assertEqualStats(t, 0, 0)
}
@@ -168,9 +167,9 @@ func TestSearch(t *testing.T) {
for i := 1; i < 51; i++ {
// search a random something that will return a single result
searchIdx := rand.Intn(4) + 1
searchIndx := rand.Intn(4) + 1
var search string
switch searchIdx {
switch searchIndx {
case 1:
search = fmt.Sprintf("from-%d@example.com", i)
case 2:
@@ -181,7 +180,7 @@ func TestSearch(t *testing.T) {
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
}
summaries, _, err := Search(search, 0, 100)
summaries, err := Search(search)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -196,8 +195,8 @@ func TestSearch(t *testing.T) {
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)
// search something that will return 200 rsults
summaries, err := Search("This is the email body")
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -231,7 +230,7 @@ func BenchmarkImportMime(b *testing.B) {
}
func setup() {
logger.NoLogging = true
config.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
@@ -263,10 +262,10 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if total != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total)
t.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
}
if unread != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread)
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
}
}

View File

@@ -1,200 +0,0 @@
package storage
import (
"bytes"
"context"
"database/sql"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/leporo/sqlf"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func dataMigrations() {
updateOrderByCreatedTask()
assignMessageIDsTask()
}
// Update Created column using Created metadata datetime <= v1.6.5
// Migration task implemented 05/2023 - can be removed end 2023
func updateOrderByCreatedTask() {
q := sqlf.From("mailbox").
Select("ID").
Select(`json_extract(Metadata, '$.Created') as Created`).
Where("Created < ?", 1155000600)
toUpdate := make(map[string]int64)
p := message.NewPrinter(language.English)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
var ts sql.NullString
if err := row.Scan(&id, &ts); err != nil {
logger.Log().Error("[migration]", err)
return
}
if !ts.Valid {
logger.Log().Errorf("[migration] cannot get Created timestamp from %s", id)
return
}
t, _ := time.Parse(time.RFC3339Nano, ts.String)
toUpdate[id] = t.UnixMilli()
}); err != nil {
logger.Log().Error("[migration]", err)
return
}
total := len(toUpdate)
if total == 0 {
return
}
logger.Log().Infof("[migration] updating timestamp for %s messages", p.Sprintf("%d", len(toUpdate)))
// begin a transaction
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Error("[migration]", err)
return
}
// roll back if it fails
defer tx.Rollback()
var blockTime = time.Now()
count := 0
for id, ts := range toUpdate {
count++
_, err := tx.Exec(`UPDATE mailbox SET Created = ? WHERE ID = ?`, ts, id)
if err != nil {
logger.Log().Error("[migration]", err)
}
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] updated timestamp for 1,000 messages [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
if err := tx.Commit(); err != nil {
logger.Log().Error("[migration]", err)
return
}
logger.Log().Infof("[migration] complete")
}
// Find any messages without a stored Message-ID and update it <= v1.6.5
// Migration task implemented 05/2023 - can be removed end 2023
func assignMessageIDsTask() {
if !config.IgnoreDuplicateIDs {
return
}
q := sqlf.From("mailbox").
Select("ID").
Where("MessageID = ''")
missingIDS := make(map[string]string)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id); err != nil {
logger.Log().Error("[migration]", err)
return
}
missingIDS[id] = ""
}); err != nil {
logger.Log().Error("[migration]", err)
}
if len(missingIDS) == 0 {
return
}
var count int
var blockTime = time.Now()
p := message.NewPrinter(language.English)
total := len(missingIDS)
logger.Log().Infof("[migration] extracting Message-IDs for %s messages", p.Sprintf("%d", total))
for id := range missingIDS {
raw, err := GetMessageRaw(id)
if err != nil {
logger.Log().Error("[migration]", err)
continue
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
logger.Log().Error("[migration]", err)
continue
}
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
missingIDS[id] = messageID
count++
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] extracted 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
// begin a transaction
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
logger.Log().Error("[migration]", err)
return
}
// roll back if it fails
defer tx.Rollback()
count = 0
for id, mid := range missingIDS {
_, err = tx.Exec(`UPDATE mailbox SET MessageID = ? WHERE ID = ?`, mid, id)
if err != nil {
logger.Log().Error("[migration]", err)
}
count++
if count%1000 == 0 {
percent := (100 * count) / total
logger.Log().Infof("[migration] stored 1,000 Message-IDs [%d%%] in %s", percent, time.Since(blockTime))
blockTime = time.Now()
}
}
logger.Log().Infof("[migration] commit %s changes", p.Sprintf("%d", count))
if err := tx.Commit(); err != nil {
logger.Log().Error("[migration]", err)
return
}
logger.Log().Infof("[migration] complete")
}

View File

@@ -10,12 +10,14 @@ import (
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string) *sqlf.Stmt {
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
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
`).OrderBy("Created DESC")
Select(`ID, Data, read,
json_extract(Data, '$.To') as ToJSON,
json_extract(Data, '$.From') as FromJSON,
json_extract(Data, '$.Subject') as Subject,
json_extract(Data, '$.Attachments') as Attachments
`).
OrderBy("Sort DESC").
Limit(200)
for _, w := range args {
if cleanString(w) == "" {
@@ -52,24 +54,6 @@ func searchParser(args []string) *sqlf.Stmt {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "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(w, "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(w, "subject:") {
w = cleanString(w[8:])
if w != "" {
@@ -79,24 +63,6 @@ func searchParser(args []string) *sqlf.Stmt {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "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(w, "tag:") {
w = cleanString(w[4:])
if w != "" {
if exclude {
q.Where("Tags NOT LIKE ?", "%\""+escPercentChar(w)+"\"%")
} else {
q.Where("Tags LIKE ?", "%\""+escPercentChar(w)+"\"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
@@ -118,9 +84,9 @@ func searchParser(args []string) *sqlf.Stmt {
} else {
// search text
if exclude {
q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}

View File

@@ -1,116 +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
// Read status
Read bool
// 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
// 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 int
// 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 int
}
// 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
// Email subject
Subject string
// Created time
Created time.Time
// Message tags
Tags []string
// Message size in bytes (total)
Size int
// Whether the message has any attachments
Attachments int
}
// MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct {
Total int
Unread int
Tags []string
}
// 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
}

View File

@@ -1,113 +0,0 @@
package storage
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/leporo/sqlf"
)
// SetTags will set the tags for a given database ID, used via API
func SetTags(id string, tags []string) error {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
sort.Strings(applyTags)
tagJSON, err := json.Marshal(applyTags)
if err != nil {
logger.Log().Errorf("[db] setting tags for message %s", id)
return err
}
_, err = sqlf.Update("mailbox").
Set("Tags", string(tagJSON)).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] set tags %s for message %s", string(tagJSON), id)
}
return err
}
// Find tags set via --tags in raw message.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 {
return tagStr
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
}
}
return tagStr
}
// 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 data string
q := sqlf.From("mailbox").
Select(`Tags`).To(&data).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
logger.Log().Error(err)
return tags
}
if err := json.Unmarshal([]byte(data), &tags); err != nil {
logger.Log().Error(err)
return tags
}
return tags
}
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
func uniqueTagsFromString(s string) []string {
tags := []string{}
if s == "" {
return tags
}
parts := strings.Split(s, ",")
for _, p := range parts {
w := tools.CleanTag(p)
if w == "" {
continue
}
if config.ValidTagRegexp.MatchString(w) {
if !inArray(w, tags) {
tags = append(tags, w)
}
} else {
logger.Log().Debugf("[db] ignoring invalid tag: %s", w)
}
}
sort.Strings(tags)
return tags
}

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/utils/logger"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/leporo/sqlf"
@@ -19,10 +19,7 @@ import (
// 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{}
}
data, _ := env.AddressList(key)
return data
}
@@ -37,12 +34,7 @@ func createSearchText(env *enmime.Envelope) string {
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
h := strings.TrimSpace(
html2text.HTML2TextWithOptions(
env.HTML,
html2text.WithLinksInnerText(),
),
)
h := strings.TrimSpace(html2text.HTML2Text(env.HTML))
if h != "" {
b.WriteString(h + " ")
} else {
@@ -61,7 +53,7 @@ func createSearchText(env *enmime.Envelope) string {
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;)`)
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|:|\,|;)`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
@@ -75,7 +67,7 @@ func dbCron() {
time.Sleep(60 * time.Second)
start := time.Now()
// check if database contains deleted data and has not been in use
// check if database contains deleted data and has not beein in use
// for 5 minutes, if so VACUUM
currentTime := time.Now()
diff := currentTime.Sub(dbLastAction)
@@ -92,7 +84,7 @@ func dbCron() {
if config.MaxMessages > 0 {
q := sqlf.Select("ID").
From("mailbox").
OrderBy("Created DESC").
OrderBy("Sort DESC").
Limit(5000).
Offset(config.MaxMessages)
@@ -167,18 +159,6 @@ func isFile(path string) bool {
return true
}
// InArray tests if a string in within an array. It is not case sensitive.
func inArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
return true
}
}
return false
}
// escPercentChar replaces `%` with `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"runtime"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/logger"
"github.com/axllent/semver"
)

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,203 +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-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,219 +0,0 @@
package htmlcheck
import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/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 {
// logger.Log().Warn(err)
inlined = html
}
// merge all CSS inline
merged, err := mergeInlineCSS(inlined)
if err != nil {
// logger.Log().Warn(err)
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().Warningf("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().Warning(err)
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 := ioutil.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/utils/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,212 +0,0 @@
package htmlcheck
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/PuerkitoBio/goquery"
"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 && !inArray(family, LimitFamilies) {
continue
}
for platform, clients := range stats.(map[string]interface{}) {
if len(LimitPlatforms) != 0 && !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
}
func inArray(n string, h []string) bool {
n = strings.ToLower(n)
for _, v := range h {
if strings.ToLower(v) == n {
return true
}
}
return false
}
// Convert markdown to HTML, stripping <p> & </p>
func mdToHTML(str string) string {
md := []byte(str)
// 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,38 +0,0 @@
package htmlcheck
import "sort"
// 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 !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,92 +0,0 @@
// Package linkcheck handles message links checking
package linkcheck
import (
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/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{}
for _, match := range linkRe.FindAllString(msg.Text, -1) {
links = append(links, match)
}
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,106 +0,0 @@
package linkcheck
import (
"net/http"
"regexp"
"sync"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/utils/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)
client := http.Client{
Timeout: timeout,
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().Error(err)
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,76 +0,0 @@
// Package logger handles the logging
package logger
import (
"encoding/json"
"fmt"
"os"
"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
)
// 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)
}
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))
}
// 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,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,59 +0,0 @@
// Package tools provides various methods for various things
package tools
import (
"bufio"
"bytes"
"net/mail"
"regexp"
"github.com/axllent/mailpit/utils/logger"
)
// RemoveMessageHeaders scans a message for headers, if found them removes them.
// It will only remove a single instance of any header, and is intended to remove
// Bcc & Message-Id.
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] removing %s header", hdr)
msg = bytes.Replace(msg, hdr, []byte(""), 1)
}
}
}
return msg, nil
}

View File

@@ -1,25 +0,0 @@
package tools
import (
"regexp"
"strings"
)
var (
// Invalid tag characters regex
tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_]`)
// Regex to catch multiple spaces
multiSpaceRe = regexp.MustCompile(`(\s+)`)
)
// CleanTag returns a clean tag, removing whitespace and invalid characters
func CleanTag(s string) string {
s = strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
return s
}