Repository: axllent/mailpit Branch: develop Commit: 7b22d6a5f9ff Files: 185 Total size: 1.6 MB Directory structure: gitextract_6s7dfcpy/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── SECURITY.md │ ├── cliff.toml │ ├── dependabot.yml │ └── workflows/ │ ├── build-docker-edge.yml │ ├── build-docker.yml │ ├── close-stale-issues.yml │ ├── codeql-analysis.yml │ ├── release-build.yml │ ├── tests-rqlite.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd/ │ ├── dump.go │ ├── ingest.go │ ├── readyz.go │ ├── reindex.go │ ├── root.go │ ├── sendmail.go │ └── version.go ├── config/ │ ├── config.go │ ├── tags.go │ ├── utils.go │ └── validators.go ├── esbuild.config.mjs ├── eslint.config.js ├── go.mod ├── go.sum ├── install.sh ├── internal/ │ ├── auth/ │ │ └── auth.go │ ├── dump/ │ │ └── dump.go │ ├── html2text/ │ │ ├── html2text.go │ │ └── html2text_test.go │ ├── htmlcheck/ │ │ ├── README.md │ │ ├── caniemail-data.json │ │ ├── caniemail.go │ │ ├── config.go │ │ ├── css.go │ │ ├── html.go │ │ ├── inline_test.go │ │ ├── main.go │ │ ├── platforms.go │ │ └── structs.go │ ├── linkcheck/ │ │ ├── linkcheck_test.go │ │ ├── main.go │ │ ├── status.go │ │ └── structs.go │ ├── logger/ │ │ └── logger.go │ ├── pop3/ │ │ ├── functions.go │ │ ├── pop3_test.go │ │ └── server.go │ ├── pop3client/ │ │ └── client.go │ ├── prometheus/ │ │ └── metrics.go │ ├── smtpd/ │ │ ├── chaos/ │ │ │ └── chaos.go │ │ ├── forward.go │ │ ├── main.go │ │ ├── relay.go │ │ ├── smtpd.go │ │ └── smtpd_test.go │ ├── snakeoil/ │ │ └── snakeoil.go │ ├── spamassassin/ │ │ ├── postmark/ │ │ │ └── postmark.go │ │ ├── spamassassin.go │ │ └── spamc/ │ │ └── spamc.go │ ├── stats/ │ │ └── stats.go │ ├── storage/ │ │ ├── cron.go │ │ ├── database.go │ │ ├── functions_test.go │ │ ├── messages.go │ │ ├── messages_test.go │ │ ├── notifications.go │ │ ├── reindex.go │ │ ├── schemas/ │ │ │ ├── 1.0.0.sql │ │ │ ├── 1.1.0.sql │ │ │ ├── 1.2.0.sql │ │ │ ├── 1.21.2.sql │ │ │ ├── 1.21.8.sql │ │ │ ├── 1.23.0.sql │ │ │ ├── 1.3.0.sql │ │ │ ├── 1.4.0.sql │ │ │ ├── 1.5.0.sql │ │ │ └── README.md │ │ ├── schemas.go │ │ ├── search.go │ │ ├── search_test.go │ │ ├── settings.go │ │ ├── structs.go │ │ ├── tagfilters.go │ │ ├── tags.go │ │ ├── tags_test.go │ │ ├── testdata/ │ │ │ ├── inline-attachment.eml │ │ │ ├── mime-attachment.eml │ │ │ ├── mixed-attachment.eml │ │ │ ├── plain-text.eml │ │ │ ├── regular-attachment.eml │ │ │ └── tags.eml │ │ └── utils.go │ └── tools/ │ ├── argsparser.go │ ├── fs.go │ ├── headers.go │ ├── html.go │ ├── listunsubscribeparser.go │ ├── net.go │ ├── snippets.go │ ├── tags.go │ ├── tools_test.go │ ├── unixsocket.go │ └── utils.go ├── main.go ├── package.json ├── sendmail/ │ ├── cmd/ │ │ ├── cmd.go │ │ └── smtp.go │ └── main.go └── server/ ├── apiv1/ │ ├── api.go │ ├── application.go │ ├── chaos.go │ ├── message.go │ ├── messages.go │ ├── other.go │ ├── release.go │ ├── send.go │ ├── structs.go │ ├── swagger-config.yml │ ├── swaggerParams.go │ ├── swaggerResponses.go │ ├── tags.go │ ├── testing.go │ └── thumbnails.go ├── cors.go ├── cors_test.go ├── embed.go ├── handlers/ │ ├── k8healthz.go │ ├── k8sready.go │ ├── messages.go │ └── proxy.go ├── server.go ├── server_test.go ├── ui/ │ └── api/ │ └── v1/ │ ├── index.html │ └── swagger.json ├── ui-src/ │ ├── App.vue │ ├── app.js │ ├── assets/ │ │ ├── _bootstrap.scss │ │ ├── _bootstrap_variables.scss │ │ └── styles.scss │ ├── components/ │ │ ├── AjaxLoader.vue │ │ ├── AppAbout.vue │ │ ├── AppBadge.vue │ │ ├── AppFavicon.vue │ │ ├── AppNotifications.vue │ │ ├── AppSettings.vue │ │ ├── EditTags.vue │ │ ├── ListMessages.vue │ │ ├── NavMailbox.vue │ │ ├── NavPagination.vue │ │ ├── NavSearch.vue │ │ ├── NavSelected.vue │ │ ├── NavTags.vue │ │ ├── SearchForm.vue │ │ └── message/ │ │ ├── HTMLCheck.vue │ │ ├── LinkCheck.vue │ │ ├── MessageAttachments.vue │ │ ├── MessageHeaders.vue │ │ ├── MessageItem.vue │ │ ├── MessageRelease.vue │ │ ├── MessageScreenshot.vue │ │ └── SpamAssassin.vue │ ├── docs.js │ ├── mixins/ │ │ ├── CommonMixins.js │ │ └── MessagesMixins.js │ ├── router/ │ │ └── index.js │ ├── stores/ │ │ ├── mailbox.js │ │ └── pagination.js │ └── views/ │ ├── MailboxView.vue │ ├── MessageView.vue │ ├── NotFoundView.vue │ └── SearchView.vue ├── webhook/ │ └── webhook.go └── websockets/ ├── client.go └── hub.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /node_modules /mailpit ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [axllent] thanks_dev: u/gh/axllent ================================================ FILE: .github/SECURITY.md ================================================ # Reporting security vulnerabilities Your efforts to responsibly disclose your findings are appreciated. **Please do not report security vulnerabilities through public GitHub issues.** If you believe you have found a security vulnerability, you can report it using one of the following methods: 1. **GitHub Security Advisory (Recommended):** Use the "Report a vulnerability" button in the [Security tab](../../security/advisories/new) of this repository. 2. **Email:** Send your findings to security@axllent.org Your report should include: - Mailpit version - A vulnerability description - Reproduction steps (if applicable) - Any other details you think are likely to be important You should receive an initial acknowledgement within 24 hours in most cases, and will be kept updated throughout the process. With your consent, your contributions will be publicly acknowledged. ================================================ FILE: .github/cliff.toml ================================================ ## https://git-cliff.org/ [changelog] body = """ {% if version %}\ \n## [{{ version }}] {% else %}\ \n## Unreleased {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | striptags | trim | upper_first }}\ {% for commit in commits %} - {{ commit.message | upper_first }}\ {% endfor %} {% endfor %}\n """ footer = "" header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file." postprocessors = [ {pattern = "reponse", replace = "response"}, {pattern = "messsage", replace = "message"}, {pattern = '(?i) go modules', replace = " Go dependencies"}, {pattern = '(?i) node modules', replace = " node dependencies"}, {pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"}, ] trim = true [git] # HTML comments added for grouping order, stripped on generation commit_parsers = [ {body = ".*security", group = "Security"}, {message = "(?i)^security", group = "Security"}, {message = "(?i)^feat", group = "Feature"}, {message = "(?i)^chore", group = "Chore"}, {message = "(?i)^libs", group = "Chore"}, {message = "(?i)^ui", group = "Chore"}, {message = "(?i)^api", group = "API"}, {message = "(?i)^fix", group = "Fix"}, {message = "(?i)^doc", group = "Documentation", default_scope = "unscoped"}, {message = "(?i)^swagger", group = "Documentation", default_scope = "unscoped"}, {message = "(?i)^test", group = "Test"}, ] # Exclude commits that are not matched by any commit parser. # filter_commits = true # Order releases topologically instead of chronologically. # topo_order = true # Order of commits in each group/release within the changelog. # Allowed values: newest, oldest sort_commits = "oldest" ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "gomod" directory: "/" # Location of package manifests schedule: interval: "quarterly" - package-ecosystem: "github-actions" directory: "/" # Location of package manifests schedule: interval: "quarterly" - package-ecosystem: "docker" directory: "/" # Location of package manifests schedule: interval: "quarterly" - package-ecosystem: "npm" directory: "/" schedule: interval: "quarterly" ================================================ FILE: .github/workflows/build-docker-edge.yml ================================================ on: push: branches: [ develop ] name: Build docker edge images jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 # required for github-action-get-previous-tag - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log into Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - name: Log into GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ github.token }} - name: Get previous git tag uses: WyriHaximus/github-action-get-previous-tag@v2 id: previous-tag - name: Get short SHA uses: benjlevesque/short-sha@v3.0 id: short-sha - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/386,linux/amd64,linux/arm64 build-args: | "VERSION=${{ steps.previous-tag.outputs.tag }}-${{ steps.short-sha.outputs.sha }}" push: true tags: | axllent/mailpit:edge ghcr.io/${{ github.repository }}:edge ================================================ FILE: .github/workflows/build-docker.yml ================================================ on: release: types: [created] name: Build docker images jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log into Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - name: Log into GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ github.token }} - name: Parse semver id: semver_parser uses: booxmedialtd/ws-action-parse-semver@v1.4.7 with: input_string: '${{ github.ref_name }}' version_extractor_regex: 'v(.*)$' - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/386,linux/amd64,linux/arm64 build-args: | "VERSION=${{ github.ref_name }}" push: true tags: | axllent/mailpit:latest axllent/mailpit:${{ github.ref_name }} axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }} ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }} ghcr.io/${{ github.repository }}:latest ================================================ FILE: .github/workflows/close-stale-issues.yml ================================================ name: Close stale issues on: schedule: - cron: "30 1 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v10.1.1 with: days-before-issue-stale: 7 days-before-issue-close: 3 exempt-issue-labels: "enhancement,bug,awaiting feedback" stale-issue-label: "stale" close-issue-reason: "completed" stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity." close-issue-message: "This issue was closed because there has been no activity since being marked as stale." days-before-pr-stale: -1 days-before-pr-close: -1 repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "develop" ] pull_request: # The branches below must be a subset of the branches above branches: [ "develop" ] schedule: - cron: '34 23 * * 4' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go', 'javascript' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ================================================ FILE: .github/workflows/release-build.yml ================================================ on: release: types: [created] name: Build & release jobs: releases-matrix: name: Build runs-on: ubuntu-latest strategy: matrix: goos: [linux, windows, darwin] goarch: ["386", amd64, arm, arm64] exclude: - goarch: "386" goos: darwin - goarch: "386" goos: windows - goarch: arm goos: darwin - goarch: arm goos: windows steps: - uses: actions/checkout@v6 # build the assets - uses: actions/setup-node@v6 with: node-version: 22 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 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} binary_name: "mailpit" pre_command: export CGO_ENABLED=0 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 }}" ================================================ FILE: .github/workflows/tests-rqlite.yml ================================================ name: Tests (rqlite) on: pull_request: branches: [ develop, 'feature/**' ] push: branches: [ develop, 'feature/**' ] jobs: test-rqlite: runs-on: ubuntu-latest services: rqlite: image: rqlite/rqlite:latest ports: - 4001:4001 env: # the HTTP address the rqlite node should advertise HTTP_ADV_ADDR: "localhost:4001" steps: - uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 with: go-version: 'stable' cache-dependency-path: "**/*.sum" - run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v env: # set Mailpit to use the rqlite service container MP_DATABASE: "http://localhost:4001" ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: pull_request: branches: [ develop, 'feature/**' ] push: branches: [ develop, 'feature/**' ] jobs: test: strategy: matrix: go-version: [stable] os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} cache: false - uses: actions/checkout@v6 - name: Set up Go environment uses: actions/cache@v5 with: path: | ~/.cache/go-build ~/go key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - name: Test Go linting (gofmt) if: startsWith(matrix.os, 'ubuntu') == true # https://olegk.dev/github-actions-and-go run: gofmt -s -w . && git diff --exit-code - name: Run Go tests run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v - name: Run Go benchmarking run: go test -p 1 ./internal/storage ./internal/html2text -bench=. # build the assets - name: Set up node environment if: startsWith(matrix.os, 'ubuntu') == true uses: actions/setup-node@v6 with: node-version: 22 cache: 'npm' - name: Install JavaScript dependencies if: startsWith(matrix.os, 'ubuntu') == true run: npm install - name: Run JavaScript linting if: startsWith(matrix.os, 'ubuntu') == true run: npm run lint - name: Test JavaScript packaging if: startsWith(matrix.os, 'ubuntu') == true run: npm run package # # validate the swagger file # - name: Validate OpenAPI definition # if: startsWith(matrix.os, 'ubuntu') == true # uses: swaggerexpert/swagger-editor-validate@v1 # with: # definition-file: server/ui/api/v1/swagger.json # default-timeout: 20000 ================================================ FILE: .gitignore ================================================ /node_modules/ /send /sendmail/sendmail /server/ui/dist /Makefile /mailpit* /.idea *.old *.db ================================================ FILE: .prettierignore ================================================ # Not within the scope of Prettier **/*.yml **/*.yaml **/*.json **/*.md **/*.css **/*.html **/*.scss composer.lock ================================================ FILE: .vscode/settings.json ================================================ { "[vue]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "cSpell.words": [ "AUTHCRAMMD", "AUTHLOGIN", "AUTHPLAIN", "bordercolor", "CRAMMD", "dateparse", "EHLO", "ESMTP", "EXPN", "gofmt", "Healthz", "HTTPIP", "Inlines", "jhillyerd", "leporo", "lithammer", "livez", "Mechs", "navhtml", "neostandard", "nolint", "popperjs", "readyz", "RSET", "shortuuid", "SMTPTLS", "swaggerexpert", "UITLS", "VRFY", "writef" ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog Notable changes to Mailpit will be documented in this file. ## [v1.29.3] ### Security - Enhance CORS origin handling to respect host:port distinctions - Limit proxy requests to 50MB to prevent OOM attacks - Enhance HTML sanitization in message view - Enhance HTML sanitization in screenshot generation - Escape ContentID in HTML replacement to prevent regex injection ### Chore - Use last release + git hash in Docker edge versions - Bump minimatch from 10.2.2 to 10.2.4 - Refactor code with go fix - Switch to math/rand/v2 - Refactor API send authentication logic - Refactor events websocket middleware - Set timeout for HTTP client in webhook Send function - Use local hostname for EHLO/HELO in SMTP communication - Simplify HTML decoding function in screenshot generation using DOMParser - Set margin & padding to HTML screenshot to prevent transparent top/left border - Replace localStorage retrieval with a dedicated function for default release addresses - Limit subject length to 100 characters in browser notifications - Improve transaction handling in pruneMessages and fix loop continuation in InitDB - Update Content-Disposition header to use inline display and escape filename - Refactor timezone handling in searchQueryBuilder - Update Go dependencies - Update node dependencies ### Fix - Update SQL query to use tenant when using is:tagged filter ## [v1.29.2] ### Security - Prevent Server-Side Request Forgery (SSRF) via Link Check API ([GHSA-mpf7-p9x7-96r3](https://github.com/axllent/mailpit/security/advisories/GHSA-mpf7-p9x7-96r3)) ### Chore - Upgrade eslint JavaScript linting - Update Go dependencies - Update node dependencies - Update caniemail test database ### Fix - Update install instructions when setting INSTALL_PATH - Include 8BITMIME in SMTPD EHLO response ([#648](https://github.com/axllent/mailpit/issues/648)) ## [v1.29.1] ### Chore - Add CORS error logging and update error messages for failed CORS requests - Bump axios from 1.13.4 to 1.13.5 - Update Go dependencies - Update node dependencies ### Fix - Enable "Mark all read" button (Inbox) when new message is received ## [v1.29.0] ### Feature - Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary - Option to display/hide attachment information in message view in web UI including checksums, content type & disposition ### Chore - Add support for multi-origin CORS settings and apply to events websocket ([#630](https://github.com/axllent/mailpit/issues/630)) - Add support for webhook delay ([#627](https://github.com/axllent/mailpit/issues/627)) - Update Go dependencies - Update node dependencies ### Test - Add CORS tests - Add message summary attachment checksum tests ## [v1.28.4] ### Chore - Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620)) - Update Go dependencies - Update node dependencies ### Fix - Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 ([#621](https://github.com/axllent/mailpit/issues/621)) - Prevent nested MAIL command during an active SMTP transaction ([#623](https://github.com/axllent/mailpit/issues/623)) - Avoid error on image type assertion in thumbnail generation ## [v1.28.3] ### Security - Ensure SMTP TO & FROM addresses are RFC 5322 compliant and prevent header injection ([GHSA-54wq-72mp-cq7c](https://github.com/axllent/mailpit/security/advisories/GHSA-54wq-72mp-cq7c)) - Prevent Server-Side Request Forgery (SSRF) via HTML Check API ([GHSA-6jxm-fv7w-rw5j](https://github.com/axllent/mailpit/security/advisories/GHSA-6jxm-fv7w-rw5j)) ### Chore - Fix formatting and update reporting instructions in SECURITY.md ([#614](https://github.com/axllent/mailpit/issues/614)) - Allow `@` character in message tags & set max length to 100 characters per tag - Update Go dependencies - Update node dependencies ### Fix - Correctly render default addresses in release modal after settings change ([#594](https://github.com/axllent/mailpit/issues/594)) - Correctly detect macOS group in install.sh ([#619](https://github.com/axllent/mailpit/issues/619)) - Auto-tagging using SMTP username using plain auth ([#617](https://github.com/axllent/mailpit/issues/617)) - Validate maximum lengths of email addresses - RFC5321 (section 4.5.3.1) ### Test - Update tag tests with length limits and `@` character - Add SMTP tests for address compliancy (RFC 5322) and header injection - Add maximum email length validation tests - RFC5321 (section 4.5.3.1) ## [v1.28.2] ### Security - Prevent Cross-Site WebSocket Hijacking (CSWSH) allowing unauthenticated access to message data [CVE-2026-22689](https://github.com/axllent/mailpit/security/advisories/GHSA-524m-q5m7-79mm) ### Feature - Allow default mail addresses to be set when releasing message ([#594](https://github.com/axllent/mailpit/issues/594)) ### Chore - Remove webkit warnings about missing template / render functions - Avoid empty URL query parameter when returning to inbox from message view ## [v1.28.1] ### Security - Restrict screenshot proxy to only support asset links contained in messages [CVE-2026-21859](https://github.com/axllent/mailpit/security/advisories/GHSA-8v65-47jx-7mfr) ### Chore - Bump actions/checkout from 5 to 6 ([#610](https://github.com/axllent/mailpit/issues/610)) - Bump actions/cache from 4 to 5 ([#607](https://github.com/axllent/mailpit/issues/607)) - Bump actions/stale from 10.0.0 to 10.1.1 ([#604](https://github.com/axllent/mailpit/issues/604)) - Bump actions/setup-node from 5 to 6 ([#598](https://github.com/axllent/mailpit/issues/598)) - Bump esbuild from 0.25.12 to 0.27.2 ([#611](https://github.com/axllent/mailpit/issues/611)) - Update Go dependencies - Update node dependencies ### Test - Add inline message tests - Increase swagger test timeout ## [v1.28.0] ### Feature - Optionally propagate SMTP errors ([#588](https://github.com/axllent/mailpit/issues/588)) ### Chore - Update Go dependencies - Update node dependencies - Update caniemail test database ## [v1.27.11] ### Chore - Update Go dependencies - Update node dependencies - Add type assertion for value in imaging assignment ## [v1.27.10] ### Security - Prevent potential information disclosure via indirect expvar library (Prometheus) ### Chore - Add tooltip to messages nav dropdown - Update GitHub Actions - Add tooltip to messages nav dropdown - Update GitHub Actions - Update Go dependencies - Update node dependencies ## [v1.27.9] ### Chore - UI tweaks to pagination layout for clearer navigation ([#568](https://github.com/axllent/mailpit/issues/568)) - Add margin to icons in release and delete buttons for consistent spacing - Update navbar theme to use data-bs-theme attribute for consistency - Update Go dependencies - Update node dependencies ## [v1.27.8] ### Chore - Update Go dependencies - Update node dependencies - Update caniemail test database ## [v1.27.7] ### Fix - Move HELO/EHLO hostname setting to the correct position in SMTP client creation ([#558](https://github.com/axllent/mailpit/issues/558)) ## [v1.27.6] ### Feature - Add optional --no-release-check to version subcommand ([#557](https://github.com/axllent/mailpit/issues/557)) ### Chore - Set HELO/EHLO hostname when connecting to external SMTP server ([#556](https://github.com/axllent/mailpit/issues/556)) - Update Go dependencies - Update node dependencies ## [v1.27.5] ### Chore - Update Go dependencies - Update node dependencies - Update caniemail test database ### Fix - Support optional UIDL argument in POP3 server ([#552](https://github.com/axllent/mailpit/issues/552)) ## [v1.27.4] ### Feature - Allow rejected SMTP recipients to be silently dropped ([#549](https://github.com/axllent/mailpit/issues/549)) ### Chore - Update Go dependencies - Update node dependencies - Update caniemail test database ## [v1.27.3] ### Fix - Fix sendmail when using an `--smtp-addr :` ([#542](https://github.com/axllent/mailpit/issues/542)) ## [v1.27.2] ### Security - Prevent integer overflow conversion to uint64 - Add ReadHeaderTimeout to Prometheus metrics server ### Feature - Add ability to generate self-signed (snakeoil) certificates for UI, SMTP and POP3 ([#539](https://github.com/axllent/mailpit/issues/539)) ### Chore - Allow sendmail to send to untrusted TLS server - Update eslint config, remove neostandard - Refactor JS functions and remove unused parameters - Update Go dependencies - Update node dependencies ### Fix - Use MaxMessages to determine pruning ([#536](https://github.com/axllent/mailpit/issues/536)) - Support angle brackets for text/plain URLs with spaces ([#535](https://github.com/axllent/mailpit/issues/535)) - Do not check latest release for Prometheus statistics ([#522](https://github.com/axllent/mailpit/issues/522)) ## [v1.27.1] ### Chore - Allow unknown href link protocols in HTML view such as myapp:// ([#532](https://github.com/axllent/mailpit/issues/532)) - Update Go dependencies - Update node dependencies ## [v1.27.0] ### Chore - Remove unused functionality/deadcode (golangci-lint) - Refactor error handling and resource management across multiple files (golangci-lint) - Refactor API Swagger definitions and remove unused structs - Bump minimum Go version to v1.24.3 for jhillyerd/enmime/v2 - Switch version checks & self-updater to use ghru/v2 - Update Go dependencies - Update node dependencies ### Fix - Align websocket new message values with global Message Summary (no null values) ([#526](https://github.com/axllent/mailpit/issues/526)) ## [v1.26.2] ### Feature - Store username with messages, auto-tag, and UI display ([#521](https://github.com/axllent/mailpit/issues/521)) - Allow version checking to be disabled ([#524](https://github.com/axllent/mailpit/issues/524)) ### Chore - Apply linting to all JavaScript/Vue files with eslint & prettier - Update Go dependencies - Update node dependencies ### Fix - Improve version polling, add thread safety and exponential backoff ([#523](https://github.com/axllent/mailpit/issues/523)) ### Test - Add JavaScript linting tests to CI - Add Go linting (gofmt) to CI ## [v1.26.1] ### Feature - Add relay config to preserve (keep) original Message-IDs when relaying messages ([#515](https://github.com/axllent/mailpit/issues/515)) ### Chore - Update Go dependencies - Update node dependencies - Update caniemail testing database ### Fix - Add optional message_num argument in POP3 LIST command ([#518](https://github.com/axllent/mailpit/issues/518)) - Use float64 for returned SQL value types for rqlite compatibility ([#520](https://github.com/axllent/mailpit/issues/520)) ### Test - Add small delay in POP3 test after disconnection to allow for background deletion in rqlite - Add automated tests using the rqlite database ## [v1.26.0] ### Feature - Send API allow separate auth ([#504](https://github.com/axllent/mailpit/issues/504)) - Add Prometheus exporter ([#505](https://github.com/axllent/mailpit/issues/505)) ### Chore - Add MP_DATA_FILE deprecation warning - Update Go dependencies - Update node dependencies ### Fix - Ignore basic auth for OPTIONS requests to API when CORS is set - Fix sendmail symlink detection for macOS ([#514](https://github.com/axllent/mailpit/issues/514)) ## [v1.25.1] ### Chore - Switch from unnecessary float64 to uint64 API values for App Information, message & attachment sizes - Extend latest version cache expiration from 5 to 15 minutes - Lighten outline-secondary buttons in dark mode - Add note to swagger docs about API date formats - Update Go dependencies - Update node dependencies ### Fix - Update bootstrap5-tags to fix text pasting in message release modal ([#498](https://github.com/axllent/mailpit/issues/498)) ## [v1.25.0] ### Feature - Add option to hide the "Delete all" button in web UI ([#495](https://github.com/axllent/mailpit/issues/495)) ### Chore - Upgrade to jhillyerd/enmime/v2 - Switch yaml parser to github.com/goccy/go-yaml - Tweak UI to improve contrast between read & unread messages - Adjust UI margin for side navigation - Update Go dependencies - Update node dependencies - Update caniemail database ### Fix - Include SMTPUTF8 capability in SMTP EHLO response ([#496](https://github.com/axllent/mailpit/issues/496)) ### Documentation - Switch to git-cliff for changelog generation - Add Message ListUnsubscribe to swagger / API documentation ([#494](https://github.com/axllent/mailpit/issues/494)) ## [v1.24.2] ### Feature - Display unread count in app badge ([#485](https://github.com/axllent/mailpit/issues/485)) ### Chore - Install script improvements & better error handling ([#482](https://github.com/axllent/mailpit/issues/482)) - Update Go dependencies - Update node dependencies - Update caniemail database ## [v1.24.1] ### Feature - Add ability to mark all search results as read ([#476](https://github.com/axllent/mailpit/issues/476)) ### Chore - Bump node version to 22 for binary releases - Improve error message for From header parsing failure ([#477](https://github.com/axllent/mailpit/issues/477)) - Update Go dependencies - Update node dependencies ## [v1.24.0] ### Feature - Add TLS relay support and refactor relay function ([#471](https://github.com/axllent/mailpit/issues/471)) - Add TLS forwarding support and refactor forwarding function ### Chore - Update Go dependencies - Standardize error message casing - Update Go dependencies - Update node dependencies ## [v1.23.2] ### Chore - Update node dependencies - Use `Message-ID` header instead of `Message-Id` when generating new IDs (RFC 5322) - Improve inline HTML Check style detection ([#467](https://github.com/axllent/mailpit/issues/467)) - Update Go dependencies ### Test - Add tests for inline HTML Checks ## [v1.23.1] ### Chore - Replace PrismJS with highlight.js for HTML syntax highlighting - Update Go dependencies - Update node dependencies ### Fix - Allow searching messages using only Cyrillic characters ([#450](https://github.com/axllent/mailpit/issues/450)) - Prevent cropping bottom of label characters in web UI ([#457](https://github.com/axllent/mailpit/issues/457)) ## [v1.23.0] ### Feature - Add configuration to set message compression level in db (0-3) ([#447](https://github.com/axllent/mailpit/issues/447) & [#448](https://github.com/axllent/mailpit/issues/448)) - Add configuration to explicitly disable HTTP compression in web UI/API ([#448](https://github.com/axllent/mailpit/issues/448)) - Add configuration to disable SQLite WAL mode for NFS compatibility ### Chore - Avoid shell in Docker health check ([#444](https://github.com/axllent/mailpit/issues/444)) - Handle BLOB storage for default database differently to rqlite to reduce memory overhead ([#447](https://github.com/axllent/mailpit/issues/447)) - Optimize ZSTD encoder for fastest compression of messages ([#447](https://github.com/axllent/mailpit/issues/447)) - Minor speed & memory improvements when storing messages - Update Go dependencies - Update node dependencies ### Fix - Display the correct STARTTLS or TLS runtime option on startup ([#446](https://github.com/axllent/mailpit/issues/446)) ### Test - Add tests for message compression levels ## [v1.22.3] ### Feature - Add dump feature to export all raw messages to a local directory ([#443](https://github.com/axllent/mailpit/issues/443)) ### Chore - Specify Docker health check start period and interval ([#439](https://github.com/axllent/mailpit/issues/439)) - Update Go dependencies - Update node dependencies ### Fix - Replace TrimLeft with TrimPrefix for webroot path handling ([#441](https://github.com/axllent/mailpit/issues/441)) - Include font/woff content type to embedded controller - Update Swagger JSON to prevent overflow ([#442](https://github.com/axllent/mailpit/issues/442)) - Correctly detect maximum SMTP recipient limits, add test ## [v1.22.2] ### Chore - Replace http.FileServer with custom controller to correctly encode gzipped error responses for embed.FS - Enable browser cache for embedded web UI assets - Update Go dependencies - Update node dependencies / esbuild ### Fix - Remove recursive HTML regeneration in embedded HTML view ([#434](https://github.com/axllent/mailpit/issues/434)) - Add missing "latest" route to message attachment API endpoint ([#437](https://github.com/axllent/mailpit/issues/437)) ## [v1.22.1] ### Feature - Add optional UI setting to skip "Delete all" & "Mark all read" confirmation dialogs([#428](https://github.com/axllent/mailpit/issues/428)) - Add optional query parameter for HTML message iframe embedding ([#434](https://github.com/axllent/mailpit/issues/434)) ### Chore - Bump actions/stale from 9.0.0 to 9.1.0 ([#432](https://github.com/axllent/mailpit/issues/432)) - Add API CORS policy to HTML preview routes ([#434](https://github.com/axllent/mailpit/issues/434)) - Update Go dependencies - Update node dependencies ## [v1.22.0] ### Feature - Add Chaos functionality to test integration handling of SMTP error responses ([#402](https://github.com/axllent/mailpit/issues/402), [#110](https://github.com/axllent/mailpit/issues/110), [#144](https://github.com/axllent/mailpit/issues/144) & [#268](https://github.com/axllent/mailpit/issues/268)) - Option to override the From email address in SMTP relay configuration ([#414](https://github.com/axllent/mailpit/issues/414)) - SMTP auto-forwarding option ([#414](https://github.com/axllent/mailpit/issues/414)) ### Chore - Update Go dependencies - Update node dependencies ### Fix - Correct date formatting in TestMakeHeaders - Update command `npm run update-caniemail` save path ([#422](https://github.com/axllent/mailpit/issues/422)) ## [v1.21.8] ### Chore - Update Go dependencies - Update node dependencies ### Fix - Remove unused FOREIGN KEY REFERENCES in message_tags table ([#374](https://github.com/axllent/mailpit/issues/374)) ## [v1.21.7] ### Chore - Display "From" details in message sidebar (desktop) ([#403](https://github.com/axllent/mailpit/issues/403)) - Display "To" details in mobile messages list - Stricter SMTP 'MAIL FROM' & 'RCPT TO' handling ([#409](https://github.com/axllent/mailpit/issues/409)) - Move smtpd & pop3 modules to internal - Bump Go version for automated testing - Update Go dependencies - Update node dependencies ### Fix - Prevent splitting multi-byte characters in message snippets ([#404](https://github.com/axllent/mailpit/issues/404)) - Ignore unsupported optional SMTP 'MAIL FROM' parameters ([#407](https://github.com/axllent/mailpit/issues/407)) ### Test - Add smtpd tests ## [v1.21.6] ### Feature - Add support for sending inline attachments via HTTP API ([#399](https://github.com/axllent/mailpit/issues/399)) - Include Mailpit label (if set) in webhook HTTP header ([#400](https://github.com/axllent/mailpit/issues/400)) ### Chore - Update Go dependencies - Update node dependencies - Update caniemail database ### Fix - Message view not updating when deleting messages from search ([#395](https://github.com/axllent/mailpit/issues/395)) ## [v1.21.5] ### Chore - Make symlink detection more specific to contain "sendmail" in the name ([#391](https://github.com/axllent/mailpit/issues/391)) - Update Go dependencies - Update node dependencies - Update caniemail database ## [v1.21.4] ### Bugfix - Fix external CSS stylesheet loading in HTML preview ([#388](https://github.com/axllent/mailpit/issues/388)) ## [v1.21.3] ### Chore - Add swagger examples & API code restructure - Upgrade Alpine packages on Docker build - Update node dependencies - Mute Dart Sass deprecation notices - Minor UI tweaks - Update Go dependencies ## [v1.21.2] ### Feature - Add additional ignored flags to sendmail ([#384](https://github.com/axllent/mailpit/issues/384)) ### Chore - Update node dependencies - Update Go dependencies - Remove legacy Tags column from message DB table ### Fix - Fix browser notification request on Edge ([#89](https://github.com/axllent/mailpit/issues/89)) ## [v1.21.1] ### Feature - Add ability to search for messages containing inline images (`has:inline`) - Add ability to search by size smaller or larger than a value (eg: `larger:1M` / `smaller:2.5M`) ### Chore - Separate attachments and inline images in download nav and badges ([#379](https://github.com/axllent/mailpit/issues/379)) - Update Go dependencies ## [v1.21.0] ### Feature - Experimental Unix socket support for HTTPD & SMTPD ([#373](https://github.com/axllent/mailpit/issues/373)) ### Fix - Allow multiple item selection on macOS with Cmd-click ([#378](https://github.com/axllent/mailpit/issues/378)) ## [v1.20.7] ### Chore - Update caniemail database ### Fix - SQL error deleting a tag while using tenant-id ([#374](https://github.com/axllent/mailpit/issues/374)) ### Test - Add tenantIDs to tests ## [v1.20.6] ### Chore - Update node dependencies - Update minimum Go version (1.22.0) - Update Go dependencies - Code cleanup - Update swagger file tests - Update node dependencies - Bump Go compile version to 1.23 ## [v1.20.5] ### Chore - Improve link detection in the HTML preview - Improve tag detection in UI - Use consistent margins for Mailpit label if set - Update node dependencies ### Fix - Use correct parameter order in SpamAssassin socket detection ([#364](https://github.com/axllent/mailpit/issues/364)) ## [v1.20.4] ### Chore - Upgrade vue-css-donut-chart & related charts - Update node dependencies - Update Go dependencies ### Fix - Relax URL detection in link check tool ([#357](https://github.com/axllent/mailpit/issues/357)) ## [v1.20.3] ### Chore - Do not recenter selected messages in sidebar on every new message - Update Go dependencies - Update node dependencies - Update caniemail database ### Fix - Disable automatic HTML/Text character detection when charset is provided ([#348](https://github.com/axllent/mailpit/issues/348)) ## [v1.20.2] ### Feature - Web UI notifications of smtpd & POP3 errors ([#347](https://github.com/axllent/mailpit/issues/347)) ### Chore - Add smtpd server logging in the CLI ([#347](https://github.com/axllent/mailpit/issues/347)) - Add debug database storage logging - Update node dependencies - Update Go dependencies ## [v1.20.1] ### Chore - Show icon attachment in new side navigation message listing ([#345](https://github.com/axllent/mailpit/issues/345)) - Live load up to 100 new messages in sidebar ([#336](https://github.com/axllent/mailpit/issues/336)) - Shift inbox pagination to inbox component ### Fix - Correctly decode X-Tags message headers (RFC 2047) ([#344](https://github.com/axllent/mailpit/issues/344)) ## [v1.20.0] ### Feature - List messages in side nav when viewing message for easy navigation ([#336](https://github.com/axllent/mailpit/issues/336)) - Add option to control message retention by age ([#338](https://github.com/axllent/mailpit/issues/338)) ### Chore - Make internal tagging methods private - Update node dependencies - Update Go dependencies - Update caniemail database ### Fix - Prevent Vue race condition to initialize dayjs relativeTime plugin - Return `text/plain` header for message delete request - Better regexp to detect tags in search - Prevent potential JavaScript errors caused by race condition ## [v1.19.3] ### Security - Prevent bypass of Contend Security Policy using stored XSS, and sanitize preview HTML data (DOMPurify) ### Chore - Display nicer noscript message when JavaScript is disabled - Update Go dependencies ## [v1.19.2] ### Chore - Update Go dependencies ### Fix - Update Inbox "Delete All" count when new messages are detected ([#334](https://github.com/axllent/mailpit/issues/334)) ## [v1.19.1] ### Feature - Add optional relay recipient blocklist ([#333](https://github.com/axllent/mailpit/issues/333)) ### Chore - Bump docker/build-push-action from 5 to 6 ([#327](https://github.com/axllent/mailpit/issues/327)) - Bump esbuild from 0.21.5 to 0.22.0 ([#326](https://github.com/axllent/mailpit/issues/326)) - Bump esbuild to version 0.23.0 - Equal column widths in About modal - Update Go dependencies ## [v1.19.0] ### Feature - Add option to disable auto-tagging for plus-addresses & X-Tags ([#323](https://github.com/axllent/mailpit/issues/323)) - Add ability to rename and delete tags globally ### Chore - Update Go dependencies - Update node dependencies ## [v1.18.7] ### Feature - Add optional label to identify Mailpit instance ([#316](https://github.com/axllent/mailpit/issues/316)) ### Chore - Handle websocket errors caused by persistent connection failures ([#319](https://github.com/axllent/mailpit/issues/319)) - Refactor JavaScript, use arrow functions instead of "self" aliasing ### Test - Add POP3 integration tests ## [v1.18.6] ### Chore - Handle POP3 RSET command - Delete multiple POP3 messages in single action - Update Go dependencies - Update node dependencies - Update caniemail database ### Fix - POP3 size output to show compatible sizes ([#312](https://github.com/axllent/mailpit/issues/312)) - POP3 end of file reached error ([#315](https://github.com/axllent/mailpit/issues/315)) ## [v1.18.5] ### Feature - Add pagination & limits to URL parameters ([#303](https://github.com/axllent/mailpit/issues/303)) ### Chore - Update Go dependencies - Update node dependencies ## [v1.18.4] ### Chore - Clone new Docker images to ghcr.io ([#302](https://github.com/axllent/mailpit/issues/302)) - Update Go dependencies - Update node dependencies ## [v1.18.3] ### Feature - ICalendar (ICS) viewer ([#298](https://github.com/axllent/mailpit/issues/298)) ### Chore - Update node dependencies - Update Go dependencies ### Fix - Add dot stuffing for POP3 ([#300](https://github.com/axllent/mailpit/issues/300)) ## [v1.18.2] ### Chore - Update node dependencies ### Fix - Replace invalid Windows username characters in sendmail ([#294](https://github.com/axllent/mailpit/issues/294)) ## [v1.18.1] ### Feature - Return queued Message ID in SMTP response ([#293](https://github.com/axllent/mailpit/issues/293)) ### Chore - Simplify JSON HTTP responses - Update Go dependencies - Update node dependencies ## [v1.18.0] ### Feature - New search filter prefix `addressed:` includes From, To, Cc, Bcc & Reply-To - Search filter support for auto-tagging - Set tagging filters via a config file - API endpoint for sending ([#278](https://github.com/axllent/mailpit/issues/278)) ### Chore - Auto-update relative received message times - Replace moment JS library with dayjs - Improve tag sorting in web UI, ignore casing - Remove function duplication - use common tools.InArray() - JSON key case-consistency for posted API data (backwards-compatible) - Update go-release-action - Update Go dependencies - Update node dependencies ## [v1.17.1] ### Chore - Clearer error messages for read/write permission failures ([#281](https://github.com/axllent/mailpit/issues/281)) - Update Go dependencies - Update node dependencies ### Fix - Prevent error when two identical tags are added at the exact same time ([#283](https://github.com/axllent/mailpit/issues/283)) ## [v1.17.0] ### Feature - Add UI settings screen - Option to auto relay for matching recipient expression only ([#274](https://github.com/axllent/mailpit/issues/274)) ### Chore - Remove deprecated --disable-html-check option - Move Link check & HTML check features out of beta - Update API documentation regarding date/time searches & timezones - Replace disintegration/imaging with kovidgoyal/imaging to fix CVE-2023-36308 - Auto-rotate thumbnail images based on exif data - Update Go dependencies - Update node dependencies - Update caniemail database ### Fix - Add delay to close database on fatal exit ([#280](https://github.com/axllent/mailpit/issues/280)) ## [v1.16.0] ### Feature - Option to use rqlite database storage ([#254](https://github.com/axllent/mailpit/issues/254)) - Add optional tenant ID to isolate data in shared databases ([#254](https://github.com/axllent/mailpit/issues/254)) - Search support for before: and after: dates ([#252](https://github.com/axllent/mailpit/issues/252)) ### Chore - Switch database flag/env to `--database` / `MP_DATABASE` - Update Go dependencies - Update node dependencies - Update caniemail test database ### Fix - Extract plus addresses from email addresses only, not names - Prevent conditional JS error when global mailbox tag list is modified via auto/plus-address tagging while viewing a message - Remove duplicated authentication check ([#276](https://github.com/axllent/mailpit/issues/276)) ## [v1.15.1] ### Feature - Add readyz subcommand for Docker healthcheck ([#270](https://github.com/axllent/mailpit/issues/270)) ### Chore - Add labels to Docker image ([#267](https://github.com/axllent/mailpit/issues/267)) - Code cleanup, remove redundant functionality ## [v1.15.0] ### Feature - Add SMTP TLS option ([#265](https://github.com/axllent/mailpit/issues/265)) ### Chore - Update Go dependencies - Update node dependencies ### Fix - Enforce SMTP STARTTLS by default if authentication is set ## [v1.14.4] ### Feature - Allow setting SMTP relay configuration values via environment variables ([#262](https://github.com/axllent/mailpit/issues/262)) ### Chore - Reorder CLI flags to group by related functionality - Update caniemail test data ## [v1.14.3] ### Chore - Update Go dependencies - Update node dependencies ### Fix - Prevent crash when calculating deleted space percentage (divide by zero) ## [v1.14.2] ### Chore - Allow setting of multiple message tags via plus addresses ([#253](https://github.com/axllent/mailpit/issues/253)) ### Fix - Prevent runtime error when calculating total messages size of empty table ([#263](https://github.com/axllent/mailpit/issues/263)) ## [v1.14.1] ### Feature - Set message tags using plus addressing ([#253](https://github.com/axllent/mailpit/issues/253)) - Option to enforce TitleCasing for all newly created tags ### Chore - Update Go dependencies - Update node dependencies - Tag names now allow `.` and must be a minimum of 1 character ### Fix - Handle null values in Mailpit settings, set DeletedSize=0 if null ## [v1.14.0] ### Feature - Optional POP3 server ([#249](https://github.com/axllent/mailpit/issues/249)) ### Chore - Better handling of automatic database compression (vacuuming) after deleting messages - Switch to short uuid format for database IDs - Security improvements (gosec) - Refactor storage library - Update Go dependencies - Update node dependencies ### Documentation - Add edge Docker images for latest unreleased features ## [v1.13.3] ### Feature - Add reply-to: search filter ([#247](https://github.com/axllent/mailpit/issues/247)) ### Chore - Update "About" modal layout when new version is available - Compress database only when >= 1% of total message size has been deleted - Update Go dependencies - Update node dependencies ### API - Include Reply-To information in message summaries for message list & websocket events ## [v1.13.2] ### Feature - Add option to log output to file ([#246](https://github.com/axllent/mailpit/issues/246)) ### Chore - Update esbuild - Bump actions build requirement versions - Update Go dependencies - Update node dependencies - Update caniemail data ## [v1.13.1] ### Feature - Add TLSRequired option for smtpd ([#241](https://github.com/axllent/mailpit/issues/241)) ### Chore - Only show number of messages ignored statistics if `--ignore-duplicate-ids` is set - Update Go dependencies - Update node dependencies ### Fix - Workaround for specific field searches containing unicode characters ([#239](https://github.com/axllent/mailpit/issues/239)) ## [v1.13.0] ### Feature - Add optional SpamAssassin integration to display scores ([#233](https://github.com/axllent/mailpit/issues/233)) - Display List-Unsubscribe & List-Unsubscribe-Post header info with syntax validation ([#236](https://github.com/axllent/mailpit/issues/236)) - Add option to disable SMTP reverse DNS (rDNS) lookup ([#230](https://github.com/axllent/mailpit/issues/230)) ### Chore - Update node dependencies - Update Go dependencies - Compress compiled assets with `npm run build` ### Fix - Sendmail support for `-f 'Name '` format - Display multiple whitespace characters in message subject & recipient names ([#238](https://github.com/axllent/mailpit/issues/238)) ## [v1.12.1] ### Feature - Add option to only allow SMTP recipients matching a regular expression (disable open-relay behaviour [#219](https://github.com/axllent/mailpit/issues/219)) ### Chore - Standardize error logging & formatting - Update node dependencies - Automatically refresh connected browsers if Mailpit is upgraded (version change) - Significantly increase database performance using WAL (Write-Ahead-Log) ### Fix - Log total deleted messages when deleting all messages from search - Prevent rare error from websocket connection (unexpected non-whitespace character) - Log total deleted messages when auto-pruning messages (--max) ### Test - Run tests on Linux, Windows & Mac ## [v1.12.0] ### Chore - Refresh search results when search resubmitted or active tag filter clicked - Standardize error logging & formatting - Convert to many-to-many message tag relationships - Update Go dependencies - Update node dependencies - Update caniemail test data - Use memory pointer for internal message parsing & storage - Include runtime statistics in API (info) & UI (About) ## [v1.11.1] ### Chore - Allow multiple tags to be searched using Ctrl-click ([#216](https://github.com/axllent/mailpit/issues/216)) - Update Go dependencies - Update node dependencies ### Fix - Fix regression to support for search query params to all `/latest` endpoints ([#206](https://github.com/axllent/mailpit/issues/206)) ### Test - Add new `ingest` subcommand to import an email file or maildir folder over SMTP ## [v1.11.0] ### Feature - Add configuration option to set maximum SMTP recipients ([#205](https://github.com/axllent/mailpit/issues/205)) ### Chore - Update Go dependencies - Update node dependencies ### API - Allow ID "latest" for message summary, headers, raw version & HTML/link checks ## [v1.10.4] ### Fix - Remove JS debug information for favicon ## [v1.10.3] ### Feature - Add @ as valid character for webroot ([#215](https://github.com/axllent/mailpit/issues/215)) ### Chore - Update caniemail library & add `hr` element test - Update Go dependencies - Update node dependencies ### Fix - New favicon notification badge to fix rendering issues ([#210](https://github.com/axllent/mailpit/issues/210)) ## [v1.10.2] ### Feature - Allow port binding using hostname ### Chore - Clearer log messages for bound SMTP & HTTP addresses - Add favicon fallback font (sans-serif) for unread count - Update Go dependencies - Update node dependencies - Enable tag colors by default ## [v1.10.1] ### Chore - Use NextReader() instead of ReadMessage() for websocket reading ([#207](https://github.com/axllent/mailpit/issues/207)) - Update Go dependencies - Update node dependencies ### Fix - Prevent JavaScript error if message is missing `From` header ([#209](https://github.com/axllent/mailpit/issues/209)) ### Documentation - Revert BinaryResponse type to string ## [v1.10.0] ### Feature - Add URL redirect (`/view/latest`) to view latest message in web UI ([#166](https://github.com/axllent/mailpit/issues/166)) - Option to allow untrusted HTTPS certificates for screenshots & link checking ([#204](https://github.com/axllent/mailpit/issues/204)) - Support search query params to /latest endpoints ([#206](https://github.com/axllent/mailpit/issues/206)) ### Chore - Update Go dependencies - Update node dependencies ### Fix - Correctly close websockets on client disconnect ([#207](https://github.com/axllent/mailpit/issues/207)) ## [v1.9.10] ### Chore - Fix column width in search view - Update caniemail test data - Update Go dependencies - Update node dependencies ### Fix - Correctly display "About" modal when update check fails (resolves [#199](https://github.com/axllent/mailpit/issues/199)) ### Documentation - Update documentation links ## [v1.9.9] ### Feature - Reset message date on release ([#194](https://github.com/axllent/mailpit/issues/194)) - Set optional webhook for received messages ([#195](https://github.com/axllent/mailpit/issues/195)) ### Chore - Move html2text module to internal/html2text - Update Go dependencies - Update node dependencies ## [v1.9.8] ### Chore - Replace html2text modules with simplified internal function - Replace satori/go.uuid with github.com/google/uuid ([#190](https://github.com/axllent/mailpit/issues/190)) - Update Go dependencies - Update node dependencies ### Documentation - Update swagger documentation ### Test - Add html2text tests - Add test to validate swagger.json ## [v1.9.7] ### Chore - Update Go dependencies & minimum Go version (1.21) - Downgrade microcosm-cc/bluemonday, revert to Go 1.20 - Update node dependencies ### Fix - Enable delete button when new messages arrive ## [v1.9.6] ### Chore - Display message previews on separate line ([#175](https://github.com/axllent/mailpit/issues/175)) - Update Go dependencies - Update node dependencies ## [v1.9.5] ### Feature - Display email previews ([#175](https://github.com/axllent/mailpit/issues/175)) - Add `reindex` subcommand to reindex all messages ### Fix - Correctly detect tags in search (UI) - HTML message preview background color when switching themes in Chrome ### Test - Add snippet tests - Add message summary tests ## [v1.9.4] ### Feature - Set auth credentials directly from environment variables ### Chore - Add option to delete a message after release - Remove some flags deprecated 08/2022 - Update Go dependencies - Update node dependencies ## [v1.9.3] ### Chore - Only queue broadcast events if clients are connected - Move utils/* packages to internal/* - Update internal import paths - Move storage package to internal/storage - Update internal/storage import paths - Display "Loading messages" instead of "No results" while loading results - Do not show excluded search tags as "current" in nav ### Test - Add tests for ArgsParser & CleanTag - Add more API tests - Add endpoints for integration tests ## [v1.9.2] ### Chore - Reset pagination when returning to inbox from search - Update node dependencies ### Fix - Delete all messages matching search when more than 1000 results ### Test - Add search delete tests - Add message tag tests ## [v1.9.1] ### Chore - Better support for mobile screen sizes - Link email addresses in message summary to search - Update Go dependencies - Update caniemail data - Set 404 page when loading a non-existent message ## [v1.9.0] ### Feature - New search filter `[!]is:tagged` - Improved search parser ### Chore - Update node dependencies - Rewrite web UI, add URL routing and components - Update Go dependencies - Update minimum Go version to 1.20 ### API - Add endpoint to return all tags in use - Delete by search filter - Remove redundant `Read` status from message (always true) ### Fix - Correctly escape certain characters in search (eg: `'`) ### Test - Bump Go version to 1.21 ## [v1.8.4] ### Fix - Correctly decode proxy links containing HTML entities (screenshots) ## [v1.8.3] ### Feature - HTML screenshots ### Chore - Group message tabs on mobile - Update node dependencies ## [v1.8.2] ### Feature - Workaround for non-RFC-compliant message headers containing - Link check to test message links ### Chore - Set hostname in page meta title to identify Mailpit instance - Update Go libs ### Build - Update wangyoucao577/go-release-action@v1.39 ## [v1.8.1] ### Chore - Update Go dependencies - Update node dependencies ### Fix - Exclude ` expectedHTMLLinks = []string{ "http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "https://localhost", "https://127.0.0.1", "http://link with spaces", "http://example.com/?blaah=yes&test=true", "http://remote-host/style.css", // css "https://example.com/image.jpg", // images } testTextLinks = `This is a line with http://example.com https://example.com HTTPS://EXAMPLE.COM [http://localhost] www.google.com < ignored |||http://example.com/?some=query-string||| // RFC2396 appendix E states angle brackets are recommended for text/plain emails to // recognize potential spaces in between the URL ` expectedTextLinks = []string{ "http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string", "https://example.com/ link with spaces", } ) func TestLinkDetection(t *testing.T) { t.Log("Testing HTML link detection") m := storage.Message{} m.Text = testTextLinks m.HTML = testHTML textLinks := extractTextLinks(&m) if !reflect.DeepEqual(textLinks, expectedTextLinks) { t.Fatalf("Failed to detect text links correctly") } htmlLinks := extractHTMLLinks(&m) if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) { t.Fatalf("Failed to detect HTML links correctly") } } ================================================ FILE: internal/linkcheck/main.go ================================================ // Package linkcheck handles message links checking package linkcheck import ( "regexp" "strings" "github.com/PuerkitoBio/goquery" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" ) var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`) // RunTests will run all tests on an HTML string func RunTests(msg *storage.Message, followRedirects bool) (Response, error) { s := Response{} allLinks := extractHTMLLinks(msg) allLinks = strUnique(append(allLinks, extractTextLinks(msg)...)) s.Links = getHTTPStatuses(allLinks, followRedirects) for _, l := range s.Links { if l.StatusCode >= 400 || l.StatusCode == 0 { s.Errors++ } } return s, nil } func extractTextLinks(msg *storage.Message) []string { testLinkRe := regexp.MustCompile(`(?im)([^<]\b)((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+))`) // RFC2396 appendix E states angle brackets are recommended for text/plain emails to // recognize potential spaces in between the URL // @see https://www.rfc-editor.org/rfc/rfc2396#appendix-E bracketLinkRe := regexp.MustCompile(`(?im)<((http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;][^>]+))>`) links := []string{} matches := testLinkRe.FindAllStringSubmatch(msg.Text, -1) for _, match := range matches { if len(match) > 0 { links = append(links, match[2]) } } angleMatches := bracketLinkRe.FindAllStringSubmatch(msg.Text, -1) for _, match := range angleMatches { if len(match) > 0 { link := strings.ReplaceAll(match[1], "\n", "") links = append(links, link) } } return links } func extractHTMLLinks(msg *storage.Message) []string { links := []string{} reader := strings.NewReader(msg.HTML) // Load the HTML document doc, err := goquery.NewDocumentFromReader(reader) if err != nil { return links } aLinks := doc.Find("a[href]").Nodes for _, link := range aLinks { l, err := tools.GetHTMLAttributeVal(link, "href") if err == nil && linkRe.MatchString(l) { links = append(links, l) } } cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes for _, link := range cssLinks { l, err := tools.GetHTMLAttributeVal(link, "href") if err == nil && linkRe.MatchString(l) { links = append(links, l) } } imgLinks := doc.Find("img[src]").Nodes for _, link := range imgLinks { l, err := tools.GetHTMLAttributeVal(link, "src") if err == nil && linkRe.MatchString(l) { links = append(links, l) } } return links } // strUnique return a slice of unique strings from a slice func strUnique(strSlice []string) []string { keys := make(map[string]bool) list := []string{} for _, entry := range strSlice { if _, value := keys[entry]; !value { keys[entry] = true list = append(list, entry) } } return list } ================================================ FILE: internal/linkcheck/status.go ================================================ package linkcheck import ( "context" "crypto/tls" "errors" "fmt" "net" "net/http" "regexp" "strings" "sync" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" ) func getHTTPStatuses(links []string, followRedirects bool) []Link { // allow 5 threads threads := make(chan int, 5) results := make(map[string]Link, len(links)) resultsMutex := sync.RWMutex{} output := []Link{} var wg sync.WaitGroup for _, l := range links { wg.Add(1) go func(link string, w *sync.WaitGroup) { threads <- 1 // will block if MAX threads defer w.Done() code, err := doHead(link, followRedirects) l := Link{} l.URL = link if err != nil { l.StatusCode = 0 l.Status = httpErrorSummary(err) if strings.Contains(l.Status, "private/reserved address") { l.Status = "Blocked private/reserved address" l.StatusCode = 451 } } else { l.StatusCode = code l.Status = http.StatusText(code) } resultsMutex.Lock() results[link] = l resultsMutex.Unlock() <-threads // remove from threads }(l, &wg) } wg.Wait() for _, l := range results { output = append(output, l) } return output } // Do a HEAD request to return HTTP status code func doHead(link string, followRedirects bool) (int, error) { if !tools.IsValidLinkURL(link) { return 0, fmt.Errorf("invalid URL: %s", link) } dialer := &net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, } tr := &http.Transport{ DialContext: safeDialContext(dialer), } if config.AllowUntrustedTLS { // user has explicitly allowed untrusted TLS, so we will not verify it for link checks tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec } client := http.Client{ Timeout: 10 * time.Second, Transport: tr, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("too many redirects") } if !followRedirects { return http.ErrUseLastResponse } if !tools.IsValidLinkURL(req.URL.String()) { return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL) } return nil }, } req, err := http.NewRequest("HEAD", link, nil) if err != nil { logger.Log().Errorf("[link-check] %s", err.Error()) return 0, err } req.Header.Set("User-Agent", "Mailpit/"+config.Version) res, err := client.Do(req) if err != nil { if res != nil { return res.StatusCode, err } return 0, err } return res.StatusCode, nil } // HTTP errors include a lot more info that just the actual error, so this // tries to take the final part of it, eg: `no such host` func httpErrorSummary(err error) string { var re = regexp.MustCompile(`.*: (.*)$`) e := err.Error() if !re.MatchString(e) { return e } parts := re.FindAllStringSubmatch(e, -1) return parts[0][len(parts[0])-1] } // SafeDialContext is a custom dialer that checks if the resolved IP addresses are internal before allowing the connection. func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, address string) (net.Conn, error) { return func(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } if !config.AllowInternalHTTPRequests { for _, ip := range ips { if tools.IsInternalIP(ip.IP) { logger.Log().Warnf("[link-check] Blocked HEAD request to private/reserved address: %s (%s)", host, ip) return nil, fmt.Errorf("blocked request to %s (%s): private/reserved address", host, ip) } } } return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) } } ================================================ FILE: internal/linkcheck/structs.go ================================================ 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"` } ================================================ FILE: internal/logger/logger.go ================================================ // Package logger handles the logging package logger import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "github.com/sirupsen/logrus" ) var ( log *logrus.Logger // VerboseLogging for verbose logging VerboseLogging bool // QuietLogging shows only errors QuietLogging bool // NoLogging shows only fatal errors NoLogging bool // LogFile sets a log file LogFile string ) // Log returns the logger instance func Log() *logrus.Logger { if log == nil { log = logrus.New() log.SetLevel(logrus.InfoLevel) if VerboseLogging { // verbose logging (debug) log.SetLevel(logrus.DebugLevel) } else if QuietLogging { // show errors only log.SetLevel(logrus.ErrorLevel) } else if NoLogging { // disable all logging (tests) log.SetLevel(logrus.PanicLevel) } if LogFile != "" { file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec if err == nil { log.Out = file } else { log.Out = os.Stdout log.Warn("Failed to log to file, using default stderr") } } else { log.Out = os.Stdout } log.SetFormatter(&logrus.TextFormatter{ FullTimestamp: true, TimestampFormat: "2006/01/02 15:04:05", }) } return log } // PrettyPrint for debugging func PrettyPrint(i any) { s, _ := json.MarshalIndent(i, "", "\t") fmt.Println(string(s)) } // CleanHTTPIP returns a human-readable IP for the logging interface // when starting services. It translates [::]: to "localhost:" func CleanHTTPIP(s string) string { re := regexp.MustCompile(`^\[\:\:\]\:\d+`) if re.MatchString(s) { return "localhost:" + s[5:] } return s } ================================================ FILE: internal/pop3/functions.go ================================================ package pop3 import ( "errors" "fmt" "net" "strings" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/websockets" ) func authUser(username, password string) bool { return auth.POP3Credentials.Match(username, password) } // Send a response with debug logging func sendResponse(c net.Conn, m string) { _, _ = fmt.Fprintf(c, "%s\r\n", m) logger.Log().Debugf("[pop3] response: %s", m) if strings.HasPrefix(m, "-ERR ") { sub, _ := strings.CutPrefix(m, "-ERR ") websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub) } } // Send a response without debug logging (for data) func sendData(c net.Conn, m string) { _, _ = fmt.Fprintf(c, "%s\r\n", m) } // Get the latest 100 messages func getMessages() ([]message, error) { messages := []message{} list, err := storage.List(0, 0, 100) if err != nil { return messages, err } for _, m := range list { msg := message{} msg.ID = m.ID msg.Size = m.Size messages = append(messages, msg) } return messages, nil } // POP3 TOP command returns the headers, followed by the next x lines func getTop(id string, nr int) (string, string, error) { var header, body string raw, err := storage.GetMessageRaw(id) if err != nil { return header, body, errors.New("-ERR no such message") } parts := strings.SplitN(string(raw), "\r\n\r\n", 2) header = parts[0] lines := []string{} if nr > 0 && len(parts) == 2 { lines = strings.SplitN(parts[1], "\r\n", nr) } return header, strings.Join(lines, "\r\n"), nil } // cuts the line into command and arguments func getCommand(line string) (string, []string) { line = strings.Trim(line, "\r \n") cmd := strings.Split(line, " ") return cmd[0], cmd[1:] } func getSafeArg(args []string, nr int) (string, error) { if nr < len(args) { return args[nr], nil } return "", errors.New("-ERR out of range") } ================================================ FILE: internal/pop3/pop3_test.go ================================================ package pop3 import ( "bytes" "fmt" "math/rand/v2" "net" "os" "strings" "testing" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/pop3client" "github.com/axllent/mailpit/internal/storage" "github.com/jhillyerd/enmime/v2" ) var ( testingPort int ) func TestPOP3(t *testing.T) { t.Log("Testing POP3 server") setup() defer storage.Close() // connect with bad password t.Log("Testing invalid login") if _, err := connectBadAuth(); err == nil { t.Error("invalid login gained access") return } t.Log("Testing valid login") c, err := connectAuth() if err != nil { t.Error(err.Error()) return } count, size, err := c.Stat() if err != nil { t.Error(err.Error()) return } assertEqual(t, count, 0, "incorrect message count") assertEqual(t, size, 0, "incorrect size") // quit else we get old data if err := c.Quit(); err != nil { t.Error(err.Error()) return } t.Log("Inserting 50 messages") insertEmailData(t) // insert 50 messages c, err = connectAuth() if err != nil { t.Error(err.Error()) return } count, _, err = c.Stat() if err != nil { t.Error(err.Error()) return } assertEqual(t, count, 50, "incorrect message count") t.Log("Fetching 20 messages") for i := 1; i <= 20; i++ { _, err := c.Retr(i) if err != nil { t.Error(err.Error()) return } } t.Log("Checking UIDL with multiple arguments") _, err = c.Cmd("UIDL", false, 1, 2, 3) if err == nil { t.Error("UIDL with multiple arguments should return an error") return } t.Log("Checking UIDL without a message id") messageIDs, err := c.Uidl(0) if err != nil { t.Error(err.Error()) return } if len(messageIDs) != 50 { assertEqual(t, len(messageIDs), 50, "incorrect UIDL message count") } t.Log("Checking UIDL with a message ID") messageIDs, err = c.Uidl(50) if err != nil { t.Error(err.Error()) return } assertEqual(t, len(messageIDs), 1, "incorrect UIDL message count") t.Log("Checking UIDL with an invalid message ID") if _, err := c.Uidl(51); err == nil { t.Errorf("UIDL 51 should return an error") return } t.Log("Deleting 25 messages") for i := 1; i <= 25; i++ { if err := c.Dele(i); err != nil { t.Error(err.Error()) return } } // messages get deleted after a QUIT if err := c.Quit(); err != nil { t.Error(err.Error()) return } // allow for background delete when using rqlite driver time.Sleep(time.Millisecond * 200) c, err = connectAuth() if err != nil { t.Error(err.Error()) return } t.Log("Fetching message count") count, _, err = c.Stat() if err != nil { t.Error(err.Error()) return } assertEqual(t, count, 25, "incorrect message count") // messages get deleted after a QUIT if err := c.Quit(); err != nil { t.Error(err.Error()) return } c, err = connectAuth() if err != nil { t.Error(err.Error()) return } t.Log("Deleting 25 messages") for i := 1; i <= 25; i++ { if err := c.Dele(i); err != nil { t.Error(err.Error()) return } } t.Log("Undeleting messages") if err := c.Rset(); err != nil { t.Error(err.Error()) return } if err := c.Quit(); err != nil { t.Error(err.Error()) return } c, err = connectAuth() if err != nil { t.Error(err.Error()) return } count, _, err = c.Stat() if err != nil { t.Error(err.Error()) return } assertEqual(t, count, 25, "incorrect message count") if err := c.Quit(); err != nil { t.Error(err.Error()) return } } func TestAuthentication(t *testing.T) { // commands only allowed after authentication authCommands := make(map[string]bool) authCommands["STAT"] = false authCommands["LIST"] = true authCommands["NOOP"] = false authCommands["RSET"] = false authCommands["RETR 1"] = true t.Log("Testing authenticated commands while not logged in") setup() defer storage.Close() insertEmailData(t) // insert 50 messages // non-authenticated connection c, err := connect() if err != nil { t.Error(err.Error()) return } for cmd, multi := range authCommands { if _, err := c.Cmd(cmd, multi); err == nil { t.Errorf("%s should require authentication", cmd) return } if _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil { t.Errorf("%s should require authentication", cmd) return } } if err := c.Quit(); err != nil { t.Error(err.Error()) return } t.Log("Testing authenticated commands while logged in") // authenticated connection c, err = connectAuth() if err != nil { t.Error(err.Error()) return } for cmd, multi := range authCommands { if _, err := c.Cmd(cmd, multi); err != nil { t.Errorf("%s should work when authenticated", cmd) return } if _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil { t.Errorf("%s should work when authenticated", cmd) return } } if err := c.Quit(); err != nil { t.Error(err.Error()) return } } func setup() { if err := auth.SetPOP3Auth("username:password"); err != nil { panic(err) } logger.NoLogging = true config.MaxMessages = 0 config.Database = os.Getenv("MP_DATABASE") var foundPort bool for !foundPort { testingPort = randRange(1111, 2000) if portFree(testingPort) { foundPort = true } } config.POP3Listen = fmt.Sprintf("localhost:%d", testingPort) if err := storage.InitDB(); err != nil { panic(err) } if err := storage.DeleteAllMessages(); err != nil { panic(err) } go Run() time.Sleep(time.Second) } // connect and authenticate func connectAuth() (*pop3client.Conn, error) { c, err := connect() if err != nil { return c, err } err = c.Auth("username", "password") return c, err } // connect and authenticate func connectBadAuth() (*pop3client.Conn, error) { c, err := connect() if err != nil { return c, err } err = c.Auth("username", "notPassword") return c, err } // connect but do not authenticate func connect() (*pop3client.Conn, error) { p := pop3client.New(pop3client.Opt{ Host: "localhost", Port: testingPort, TLSEnabled: false, }) c, err := p.NewConn() if err != nil { return c, err } return c, err } func portFree(port int) bool { ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) if err != nil { return false } if err := ln.Close(); err != nil { panic(err) } return true } func randRange(min, max int) int { return rand.IntN(max-min) + min } func insertEmailData(t *testing.T) { for i := range 50 { msg := enmime.Builder(). From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)). Subject(fmt.Sprintf("Subject line %d end", i)). Text(fmt.Appendf(nil, "This is the email body %d .", i)). To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)) env, err := msg.Build() if err != nil { t.Log("error ", err) t.Fail() } buf := new(bytes.Buffer) if err := env.Encode(buf); err != nil { t.Log("error ", err) t.Fail() } bufBytes := buf.Bytes() id, err := storage.Store(&bufBytes, nil) if err != nil { t.Log("error ", err) t.Fail() } if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { t.Log("error ", err) t.Fail() } } } func assertEqual(t *testing.T, a any, b any, message string) { if a == b { return } message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b) t.Fatal(message) } ================================================ FILE: internal/pop3/server.go ================================================ // Package pop3 is a simple POP3 server for Mailpit. // By default it is disabled unless password credentials have been loaded. // // References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket // See RFC: https://datatracker.ietf.org/doc/html/rfc1939 package pop3 import ( "bufio" "crypto/tls" "fmt" "io" "net" "strconv" "strings" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/websockets" ) const ( // AUTHORIZATION is the initial state AUTHORIZATION = 1 // TRANSACTION is the state after login TRANSACTION = 2 // UPDATE is the state before closing UPDATE = 3 ) // Run will start the POP3 server if enabled func Run() { if auth.POP3Credentials == nil || config.POP3Listen == "" { // POP3 server is disabled without authentication return } var listener net.Listener var err error if config.POP3TLSCert != "" { cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey) if err2 != nil { logger.Log().Errorf("[pop3] %s", err2.Error()) return } tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12, } listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig) } else { // unencrypted listener, err = net.Listen("tcp", config.POP3Listen) } if err != nil { logger.Log().Errorf("[pop3] %s", err.Error()) return } logger.Log().Infof("[pop3] starting on %s", config.POP3Listen) for { conn, err := listener.Accept() if err != nil { logger.Log().Errorf("[pop3] accept error: %s", err.Error()) continue } // run as goroutine go handleClient(conn) } } type message struct { ID string Size uint64 } func handleClient(conn net.Conn) { var ( user = "" state = AUTHORIZATION // Start with AUTHORIZATION state toDelete []string // Track messages marked for deletion messages []message ) defer func() { if state == UPDATE { if len(toDelete) > 0 { if err := storage.DeleteMessages(toDelete); err != nil { logger.Log().Errorf("[pop3] error deleting: %s", err.Error()) } // Update web UI to remove deleted messages websockets.Broadcast("prune", nil) } } if err := conn.Close(); err != nil { logger.Log().Errorf("[pop3] %s", err.Error()) } }() reader := bufio.NewReader(conn) logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String()) // First welcome the new connection serverName := "Mailpit" if config.Label != "" { serverName = fmt.Sprintf("Mailpit (%s)", config.Label) } sendResponse(conn, fmt.Sprintf("+OK %s POP3 server", serverName)) // Set 10 minutes timeout according to RFC1939 timeoutDuration := 600 * time.Second for { // Set read deadline if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil { logger.Log().Errorf("[pop3] %s", err.Error()) return } // Reads a line from the client rawLine, err := reader.ReadString('\n') if err != nil { if err == io.EOF { logger.Log().Debugf("[pop3] client disconnected: %s", conn.RemoteAddr().String()) } else { logger.Log().Errorf("[pop3] read error: %s", err.Error()) } return } // Parses the command cmd, args := getCommand(rawLine) cmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String()) switch cmd { case "CAPA": // List our capabilities per RFC2449 sendResponse(conn, "+OK capability list follows") sendResponse(conn, "TOP") sendResponse(conn, "USER") sendResponse(conn, "UIDL") sendResponse(conn, "IMPLEMENTATION Mailpit") sendResponse(conn, ".") case "USER": if state == AUTHORIZATION { if len(args) != 1 { sendResponse(conn, "-ERR must supply a user") return } sendResponse(conn, "+OK") user = args[0] } else { sendResponse(conn, "-ERR user already specified") } case "PASS": if state == AUTHORIZATION { if user == "" { sendResponse(conn, "-ERR must supply a user") return } if len(args) != 1 { sendResponse(conn, "-ERR must supply a password") return } pass := args[0] if authUser(user, pass) { sendResponse(conn, "+OK signed in") var err error messages, err = getMessages() if err != nil { logger.Log().Errorf("[pop3] %s", err.Error()) } state = TRANSACTION } else { sendResponse(conn, "-ERR invalid password") logger.Log().Warnf("[pop3] failed login: %s", user) } } else { sendResponse(conn, "-ERR user not specified") } case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE", "RSET": if state == TRANSACTION { handleTransactionCommand(conn, cmd, args, messages, &toDelete) } else { sendResponse(conn, "-ERR user not authenticated") } case "QUIT": sendResponse(conn, "+OK goodbye") state = UPDATE return default: sendResponse(conn, "-ERR unknown command") } } } func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) { switch cmd { case "STAT": totalSize := uint64(0) for _, m := range messages { totalSize += m.Size } sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize)) case "LIST": totalSize := uint64(0) for _, m := range messages { totalSize += m.Size } if len(args) > 0 { arg, _ := getSafeArg(args, 0) nr, err := strconv.Atoi(arg) if err != nil || nr < 1 || nr > len(messages) { sendResponse(conn, "-ERR no such message") return } sendResponse(conn, fmt.Sprintf("+OK %d %d", nr, messages[nr-1].Size)) } else { sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize)) for row, m := range messages { sendResponse(conn, fmt.Sprintf("%d %d", row+1, m.Size)) } sendResponse(conn, ".") } case "UIDL": if len(args) > 1 { sendResponse(conn, "-ERR UIDL takes at most one argument") } else if len(args) == 1 { nr, err := strconv.Atoi(args[0]) if err != nil { sendResponse(conn, "-ERR no such message") return } if nr < 1 || nr > len(messages) { sendResponse(conn, "-ERR no such message") return } m := messages[nr-1] sendResponse(conn, fmt.Sprintf("+OK %d %s", nr, m.ID)) } else { sendResponse(conn, "+OK unique-id listing follows") for row, m := range messages { sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID)) } sendResponse(conn, ".") } case "RETR": if len(args) != 1 { sendResponse(conn, "-ERR no such message") return } nr, err := strconv.Atoi(args[0]) if err != nil || nr < 1 || nr > len(messages) { sendResponse(conn, "-ERR no such message") return } m := messages[nr-1] raw, err := storage.GetMessageRaw(m.ID) if err != nil { sendResponse(conn, "-ERR no such message") return } size := len(raw) sendResponse(conn, fmt.Sprintf("+OK %d octets", size)) // When all lines of the response have been sent, a // final line is sent, consisting of a termination octet (decimal code // 046, ".") and a CRLF pair. If any line of the multi-line response // begins with the termination octet, the line is "byte-stuffed" by // pre-pending the termination octet to that line of the response. // @see: https://www.ietf.org/rfc/rfc1939.txt sendData(conn, strings.ReplaceAll(string(raw), "\n.", "\n..")) sendResponse(conn, ".") case "TOP": arg, err := getSafeArg(args, 0) if err != nil { sendResponse(conn, "-ERR TOP requires two arguments") return } nr, err := strconv.Atoi(arg) if err != nil || nr < 1 || nr > len(messages) { sendResponse(conn, "-ERR no such message") return } arg2, err := getSafeArg(args, 1) if err != nil { sendResponse(conn, "-ERR TOP requires two arguments") return } lines, err := strconv.Atoi(arg2) if err != nil { sendResponse(conn, "-ERR TOP requires two arguments") return } m := messages[nr-1] headers, body, err := getTop(m.ID, lines) if err != nil { sendResponse(conn, err.Error()) return } sendResponse(conn, "+OK top of message follows") sendData(conn, headers+"\r\n") sendData(conn, body) sendResponse(conn, ".") case "NOOP": sendResponse(conn, "+OK") case "DELE": arg, _ := getSafeArg(args, 0) nr, err := strconv.Atoi(arg) if err != nil || nr < 1 || nr > len(messages) { sendResponse(conn, "-ERR no such message") return } m := messages[nr-1] *toDelete = append(*toDelete, m.ID) sendResponse(conn, "+OK message marked for deletion") case "RSET": *toDelete = []string{} sendResponse(conn, "+OK") default: sendResponse(conn, "-ERR unknown command") } } ================================================ FILE: internal/pop3client/client.go ================================================ // Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies. // This is used solely for testing the POP3 server package pop3client import ( "bufio" "bytes" "crypto/tls" "errors" "fmt" "net" "net/mail" "strconv" "strings" "time" ) // Client implements a Client e-mail client. type Client struct { opt Opt dialer Dialer } // Conn is a stateful connection with the POP3 server/ type Conn struct { conn net.Conn r *bufio.Reader w *bufio.Writer } // Opt represents the client configuration. type Opt struct { // Host name Host string `json:"host"` // Port number Port int `json:"port"` // DialTimeout default is 3 seconds. DialTimeout time.Duration `json:"dial_timeout"` // Dialer Dialer Dialer `json:"-"` // TLSEnabled sets whether SLS is enabled TLSEnabled bool `json:"tls_enabled"` // TLSSkipVerify skips TLS verification (ie: self-signed) TLSSkipVerify bool `json:"tls_skip_verify"` } // Dialer interface type Dialer interface { Dial(network, address string) (net.Conn, error) } // MessageID contains the ID and size of an individual message. type MessageID struct { // ID is the numerical index (non-unique) of the message. ID int // Size in bytes Size int // UID is only present if the response is to the UIDL command. UID string } var ( lineBreak = []byte("\r\n") respOK = []byte("+OK") // `+OK` without additional info respOKInfo = []byte("+OK ") // `+OK ` respErr = []byte("-ERR") // `-ERR` without additional info respErrInfo = []byte("-ERR ") // `-ERR ` ) // New returns a new client object using an existing connection. func New(opt Opt) *Client { if opt.DialTimeout < time.Millisecond { opt.DialTimeout = time.Second * 3 } c := &Client{ opt: opt, dialer: opt.Dialer, } if c.dialer == nil { c.dialer = &net.Dialer{Timeout: opt.DialTimeout} } return c } // NewConn creates and returns live POP3 server connection. func (c *Client) NewConn() (*Conn, error) { var ( addr = fmt.Sprintf("%s:%d", c.opt.Host, c.opt.Port) ) conn, err := c.dialer.Dial("tcp", addr) if err != nil { return nil, err } // No TLS. if c.opt.TLSEnabled { // Skip TLS host verification. tlsCfg := tls.Config{} // #nosec if c.opt.TLSSkipVerify { tlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec } else { tlsCfg.ServerName = c.opt.Host } conn = tls.Client(conn, &tlsCfg) } pCon := &Conn{ conn: conn, r: bufio.NewReader(conn), w: bufio.NewWriter(conn), } // Verify the connection by reading the welcome +OK greeting. if _, err := pCon.ReadOne(); err != nil { return nil, err } return pCon, nil } // Send sends a POP3 command to the server. The given comand is suffixed with "\r\n". func (c *Conn) Send(b string) error { if _, err := c.w.WriteString(b + "\r\n"); err != nil { return err } return c.w.Flush() } // Cmd sends a command to the server. POP3 responses are either single line or multi-line. // The first line always with -ERR in case of an error or +OK in case of a successful operation. // OK+ is always followed by a response on the same line which is either the actual response data // in case of single line responses, or a help message followed by multiple lines of actual response // data in case of multiline responses. // See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples. func (c *Conn) Cmd(cmd string, isMulti bool, args ...any) (*bytes.Buffer, error) { var cmdLine string // Repeat a %v to format each arg. if len(args) > 0 { format := " " + strings.TrimRight(strings.Repeat("%v ", len(args)), " ") // CMD arg1 argn ...\r\n cmdLine = fmt.Sprintf(cmd+format, args...) } else { cmdLine = cmd } if err := c.Send(cmdLine); err != nil { return nil, err } // Read the first line of response to get the +OK/-ERR status. b, err := c.ReadOne() if err != nil { return nil, err } // Single line response. if !isMulti { return bytes.NewBuffer(b), err } buf, err := c.ReadAll() return buf, err } // ReadOne reads a single line response from the conn. func (c *Conn) ReadOne() ([]byte, error) { b, _, err := c.r.ReadLine() if err != nil { return nil, err } r, err := parseResp(b) return r, err } // ReadAll reads all lines from the connection until the POP3 multiline terminator "." is encountered // and returns a bytes.Buffer of all the read lines. func (c *Conn) ReadAll() (*bytes.Buffer, error) { buf := &bytes.Buffer{} for { b, _, err := c.r.ReadLine() if err != nil { return nil, err } // "." indicates the end of a multi-line response. if bytes.Equal(b, []byte(".")) { break } if _, err := buf.Write(b); err != nil { return nil, err } if _, err := buf.Write(lineBreak); err != nil { return nil, err } } return buf, nil } // Auth authenticates the given credentials with the server. func (c *Conn) Auth(user, password string) error { if err := c.User(user); err != nil { return err } if err := c.Pass(password); err != nil { return err } // Issue a NOOP to force the server to respond to the auth. // Courtesy: github.com/TheCreeper/go-pop3 return c.Noop() } // User sends the username to the server. func (c *Conn) User(s string) error { _, err := c.Cmd("USER", false, s) return err } // Pass sends the password to the server. func (c *Conn) Pass(s string) error { _, err := c.Cmd("PASS", false, s) return err } // Stat returns the number of messages and their total size in bytes in the inbox. func (c *Conn) Stat() (int, int, error) { b, err := c.Cmd("STAT", false) if err != nil { return 0, 0, err } // count size f := bytes.Fields(b.Bytes()) // Total number of messages. count, err := strconv.Atoi(string(f[0])) if err != nil { return 0, 0, err } if count == 0 { return 0, 0, nil } // Total size of all messages in bytes. size, err := strconv.Atoi(string(f[1])) if err != nil { return 0, 0, err } return count, size, nil } // List returns a list of (message ID, message Size) pairs. // If the optional msgID > 0, then only that particular message is listed. // The message IDs are sequential, 1 to N. func (c *Conn) List(msgID int) ([]MessageID, error) { var ( buf *bytes.Buffer err error ) if msgID <= 0 { // Multiline response listing all messages. buf, err = c.Cmd("LIST", true) } else { // Single line response listing one message. buf, err = c.Cmd("LIST", false, msgID) } if err != nil { return nil, err } var ( out []MessageID lines = bytes.Split(buf.Bytes(), lineBreak) ) for _, l := range lines { // id size f := bytes.Fields(l) if len(f) == 0 { break } id, err := strconv.Atoi(string(f[0])) if err != nil { return nil, err } size, err := strconv.Atoi(string(f[1])) if err != nil { return nil, err } out = append(out, MessageID{ID: id, Size: size}) } return out, nil } // Uidl returns a list of (message ID, message UID) pairs. If the optional msgID // is > 0, then only that particular message is listed. It works like Top() but only works on // servers that support the UIDL command. Messages size field is not available in the UIDL response. func (c *Conn) Uidl(msgID int) ([]MessageID, error) { var ( buf *bytes.Buffer err error ) if msgID <= 0 { // Multiline response listing all messages. buf, err = c.Cmd("UIDL", true) } else { // Single line response listing one message. buf, err = c.Cmd("UIDL", false, msgID) } if err != nil { return nil, err } var ( out []MessageID lines = bytes.Split(buf.Bytes(), lineBreak) ) for _, l := range lines { // id size f := bytes.Fields(l) if len(f) == 0 { break } id, err := strconv.Atoi(string(f[0])) if err != nil { return nil, err } out = append(out, MessageID{ID: id, UID: string(f[1])}) } return out, nil } // Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message. func (c *Conn) Retr(msgID int) (*mail.Message, error) { b, err := c.Cmd("RETR", true, msgID) if err != nil { return nil, err } m, err := mail.ReadMessage(b) if err != nil { return nil, err } return m, nil } // RetrRaw downloads a message by the given msgID and returns the raw []byte // of the entire message. func (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) { b, err := c.Cmd("RETR", true, msgID) return b, err } // Top retrieves a message by its ID with full headers and numLines lines of the body. func (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) { b, err := c.Cmd("TOP", true, msgID, numLines) if err != nil { return nil, err } m, err := mail.ReadMessage(b) if err != nil { return nil, err } return m, nil } // Dele deletes one or more messages. The server only executes the // deletions after a successful Quit(). func (c *Conn) Dele(msgID ...int) error { for _, id := range msgID { _, err := c.Cmd("DELE", false, id) if err != nil { return err } } return nil } // Rset clears the messages marked for deletion in the current session. func (c *Conn) Rset() error { _, err := c.Cmd("RSET", false) return err } // Noop issues a do-nothing NOOP command to the server. This is useful for // prolonging open connections. func (c *Conn) Noop() error { _, err := c.Cmd("NOOP", false) return err } // Quit sends the QUIT command to server and gracefully closes the connection. // Message deletions (DELE command) are only executed by the server on a graceful // quit and close. func (c *Conn) Quit() error { defer func() { _ = c.conn.Close() }() if _, err := c.Cmd("QUIT", false); err != nil { return err } return nil } // parseResp checks if the response is an error that starts with `-ERR` // and returns an error with the message that succeeds the error indicator. // For success `+OK` messages, it returns the remaining response bytes. func parseResp(b []byte) ([]byte, error) { if len(b) == 0 { return nil, nil } if bytes.Equal(b, respOK) { return nil, nil } else if after, ok := bytes.CutPrefix(b, respOKInfo); ok { return after, nil } else if bytes.Equal(b, respErr) { return nil, errors.New("unknown error (no info specified in response)") } else if after, ok := bytes.CutPrefix(b, respErrInfo); ok { return nil, errors.New(string(after)) } return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b)) } ================================================ FILE: internal/prometheus/metrics.go ================================================ // Package prometheus provides Prometheus metrics for Mailpit package prometheus import ( "net/http" "strings" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/stats" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) var ( // Registry is the Prometheus registry for Mailpit metrics Registry = prometheus.NewRegistry() // Metrics totalMessages prometheus.Gauge unreadMessages prometheus.Gauge databaseSize prometheus.Gauge messagesDeleted prometheus.Counter smtpAccepted prometheus.Counter smtpRejected prometheus.Counter smtpIgnored prometheus.Counter smtpAcceptedSize prometheus.Counter uptime prometheus.Gauge memoryUsage prometheus.Gauge tagCounters *prometheus.GaugeVec ) // InitMetrics initializes all Prometheus metrics func initMetrics() { // Create metrics totalMessages = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "mailpit_messages", Help: "Total number of messages in the database", }) unreadMessages = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "mailpit_messages_unread", Help: "Number of unread messages in the database", }) databaseSize = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "mailpit_database_size_bytes", Help: "Size of the database in bytes", }) messagesDeleted = prometheus.NewCounter(prometheus.CounterOpts{ Name: "mailpit_messages_deleted_total", Help: "Total number of messages deleted", }) smtpAccepted = prometheus.NewCounter(prometheus.CounterOpts{ Name: "mailpit_smtp_accepted_total", Help: "Total number of SMTP messages accepted", }) smtpRejected = prometheus.NewCounter(prometheus.CounterOpts{ Name: "mailpit_smtp_rejected_total", Help: "Total number of SMTP messages rejected", }) smtpIgnored = prometheus.NewCounter(prometheus.CounterOpts{ Name: "mailpit_smtp_ignored_total", Help: "Total number of SMTP messages ignored (duplicates)", }) smtpAcceptedSize = prometheus.NewCounter(prometheus.CounterOpts{ Name: "mailpit_smtp_accepted_size_bytes_total", Help: "Total size of accepted SMTP messages in bytes", }) uptime = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "mailpit_uptime_seconds", Help: "Uptime of Mailpit in seconds", }) memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "mailpit_memory_usage_bytes", Help: "Memory usage in bytes", }) tagCounters = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "mailpit_tag_messages", Help: "Number of messages per tag", }, []string{"tag"}, ) // Register metrics Registry.MustRegister(totalMessages) Registry.MustRegister(unreadMessages) Registry.MustRegister(databaseSize) Registry.MustRegister(messagesDeleted) Registry.MustRegister(smtpAccepted) Registry.MustRegister(smtpRejected) Registry.MustRegister(smtpIgnored) Registry.MustRegister(smtpAcceptedSize) Registry.MustRegister(uptime) Registry.MustRegister(memoryUsage) Registry.MustRegister(tagCounters) } // UpdateMetrics updates all metrics with current values func updateMetrics() { info := stats.Load(false) totalMessages.Set(float64(info.Messages)) unreadMessages.Set(float64(info.Unread)) databaseSize.Set(float64(info.DatabaseSize)) messagesDeleted.Add(float64(info.RuntimeStats.MessagesDeleted)) smtpAccepted.Add(float64(info.RuntimeStats.SMTPAccepted)) smtpRejected.Add(float64(info.RuntimeStats.SMTPRejected)) smtpIgnored.Add(float64(info.RuntimeStats.SMTPIgnored)) smtpAcceptedSize.Add(float64(info.RuntimeStats.SMTPAcceptedSize)) uptime.Set(float64(info.RuntimeStats.Uptime)) memoryUsage.Set(float64(info.RuntimeStats.Memory)) // Reset tag counters tagCounters.Reset() // Update tag counters for tag, count := range info.Tags { tagCounters.WithLabelValues(tag).Set(float64(count)) } } // GetHandler returns the Prometheus handler & disables double compression in middleware func GetHandler() http.Handler { return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{ DisableCompression: true, }) } // StartUpdater starts the periodic metrics update routine func StartUpdater() { initMetrics() updateMetrics() // Start periodic updates go func() { ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() for range ticker.C { updateMetrics() } }() } // StartSeparateServer starts a separate HTTP server for Prometheus metrics func StartSeparateServer() { StartUpdater() logger.Log().Infof("[prometheus] metrics server listening on %s", config.PrometheusListen) // Create a dedicated mux for the metrics server mux := http.NewServeMux() mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{})) // Create a dedicated server instance server := &http.Server{ Addr: config.PrometheusListen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } // Start HTTP server if err := server.ListenAndServe(); err != nil { logger.Log().Errorf("[prometheus] metrics server error: %s", err.Error()) } } // GetMode returns the Prometheus run mode func GetMode() string { mode := strings.ToLower(strings.TrimSpace(config.PrometheusListen)) switch mode { case "false", "": return "disabled" case "true": return "integrated" default: return "separate" } } ================================================ FILE: internal/smtpd/chaos/chaos.go ================================================ // Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server. // See https://en.wikipedia.org/wiki/Chaos_engineering // See https://mailpit.axllent.org/docs/integration/chaos/ package chaos import ( "crypto/rand" "fmt" "math/big" "strings" "github.com/axllent/mailpit/internal/logger" ) var ( // Enabled is a flag to enable or disable support for chaos Enabled = false // Config is the global Chaos configuration Config = Triggers{ Sender: Trigger{ErrorCode: 451, Probability: 0}, Recipient: Trigger{ErrorCode: 451, Probability: 0}, Authentication: Trigger{ErrorCode: 535, Probability: 0}, } ) // Triggers for the Chaos configuration // // swagger:model ChaosTriggers type Triggers struct { // Sender trigger to fail on From, Sender Sender Trigger // Recipient trigger to fail on To, Cc, Bcc Recipient Trigger // Authentication trigger to fail while authenticating (auth must be configured) Authentication Trigger } // Trigger for Chaos // // swagger:model ChaosTrigger type Trigger struct { // SMTP error code to return. The value must range from 400 to 599. // required: true // example: 451 ErrorCode int // Probability (chance) of triggering the error. The value must range from 0 to 100. // required: true // example: 5 Probability int } // SetFromStruct will set a whole map of chaos configurations (ie: API) func SetFromStruct(c Triggers) error { if c.Sender.ErrorCode == 0 { c.Sender.ErrorCode = 451 // default } if c.Recipient.ErrorCode == 0 { c.Recipient.ErrorCode = 451 // default } if c.Authentication.ErrorCode == 0 { c.Authentication.ErrorCode = 535 // default } if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil { return err } if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil { return err } if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil { return err } return nil } // Set will set the chaos configuration for the given key (CLI & setMap()) func Set(key string, errorCode int, probability int) error { Enabled = true if errorCode < 400 || errorCode > 599 { return fmt.Errorf("error code must be between 400 and 599") } if probability > 100 || probability < 0 { return fmt.Errorf("probability must be between 0 and 100") } key = strings.ToLower(key) switch key { case "sender": Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability} logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability) case "recipient", "recipients": Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability} logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability) case "auth", "authentication": Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability} logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability) default: return fmt.Errorf("unknown key %s", key) } return nil } // Trigger will return whether the Chaos rule is triggered based on the configuration // and a randomly-generated percentage value. func (c Trigger) Trigger() (bool, int) { if !Enabled || c.Probability == 0 { return false, 0 } nBig, _ := rand.Int(rand.Reader, big.NewInt(100)) // rand.IntN(100) will return 0-99, whereas probability is 1-100, // so value must be less than (not <=) to the probability to trigger return int(nBig.Int64()) < c.Probability, c.ErrorCode } ================================================ FILE: internal/smtpd/forward.go ================================================ package smtpd import ( "crypto/tls" "fmt" "net/smtp" "os" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/pkg/errors" ) // Wrapper to forward messages if configured func autoForwardMessage(from string, data *[]byte) error { if config.SMTPForwardConfig.Host == "" { return nil } if err := forward(from, *data); err != nil { return errors.WithMessage(err, "[forward] error: %s") } logger.Log().Debugf( "[forward] message from %s to %s via %s:%d", from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port, ) return nil } func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) { if config.TLS { tlsConf := &tls.Config{ServerName: config.Host} // #nosec tlsConf.InsecureSkipVerify = config.AllowInsecure conn, err := tls.Dial("tcp", addr, tlsConf) if err != nil { return nil, fmt.Errorf("TLS dial error: %v", err) } client, err := smtp.NewClient(conn, tlsConf.ServerName) if err != nil { _ = conn.Close() return nil, fmt.Errorf("SMTP client error: %v", err) } // Note: The caller is responsible for closing the client return client, nil } client, err := smtp.Dial(addr) if err != nil { return nil, fmt.Errorf("error connecting to %s: %v", addr, err) } // Set the hostname for HELO/EHLO if hostname, err := os.Hostname(); err == nil { if err := client.Hello(hostname); err != nil { return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err) } } if config.STARTTLS { tlsConf := &tls.Config{ServerName: config.Host} // #nosec tlsConf.InsecureSkipVerify = config.AllowInsecure if err = client.StartTLS(tlsConf); err != nil { _ = client.Close() return nil, fmt.Errorf("error creating StartTLS config: %v", err) } } // Note: The caller is responsible for closing the client return client, nil } // Forward will connect to a pre-configured SMTP server and send a message to one or more recipients. func forward(from string, msg []byte) error { addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port) c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr) if err != nil { return err } defer func() { _ = c.Close() }() auth := forwardAuthFromConfig() if auth != nil { if err = c.Auth(auth); err != nil { return fmt.Errorf("error response to AUTH command: %s", err.Error()) } } if config.SMTPForwardConfig.OverrideFrom != "" { msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom) if err != nil { return fmt.Errorf("error overriding From header: %s", err.Error()) } from = config.SMTPForwardConfig.OverrideFrom } if err = c.Mail(from); err != nil { return fmt.Errorf("error response to MAIL command: %s", err.Error()) } to := strings.SplitSeq(config.SMTPForwardConfig.To, ",") for addr := range to { if err = c.Rcpt(addr); err != nil { logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error()) if config.SMTPForwardConfig.ForwardSMTPErrors { return errors.WithMessagef(err, "error response to RCPT command for %s", addr) } } } w, err := c.Data() if err != nil { return fmt.Errorf("error response to DATA command: %s", err.Error()) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("error sending message: %s", err.Error()) } if err := w.Close(); err != nil { return fmt.Errorf("error closing connection: %s", err.Error()) } return c.Quit() } // Return the SMTP forwarding authentication based on config func forwardAuthFromConfig() smtp.Auth { var a smtp.Auth if config.SMTPForwardConfig.Auth == "plain" { a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host) } if config.SMTPForwardConfig.Auth == "login" { a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password) } if config.SMTPForwardConfig.Auth == "cram-md5" { a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret) } return a } ================================================ FILE: internal/smtpd/main.go ================================================ // Package smtpd is the SMTP daemon package smtpd import ( "bytes" "fmt" "net" "net/mail" "regexp" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/stats" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/websockets" "github.com/lithammer/shortuuid/v4" "github.com/pkg/errors" ) var ( // DisableReverseDNS allows rDNS to be disabled DisableReverseDNS bool warningResponse = regexp.MustCompile(`^4\d\d `) errorResponse = regexp.MustCompile(`^5\d\d `) ) // MailHandler handles the incoming message to store in the database func mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) { return SaveToDatabase(origin, from, to, data, smtpUser) } // SaveToDatabase will attempt to save a message to the database func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) { if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) { // replace all (\r\r\n) with (\r\n) // @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153 data = bytes.ReplaceAll(data, []byte("\r\r\n"), []byte("\r\n")) } msg, err := mail.ReadMessage(bytes.NewReader(data)) if err != nil { logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error()) stats.LogSMTPRejected() return "", err } // check / set the Return-Path based on SMTP from returnPath := strings.Trim(msg.Header.Get("Return-Path"), "<>") if returnPath != from { data, err = tools.SetMessageHeader(data, "Return-Path", "<"+from+">") if err != nil { return "", err } } messageID := strings.Trim(msg.Header.Get("Message-ID"), "<>") // add a message ID if not set if messageID == "" { // generate unique ID messageID = shortuuid.New() + "@mailpit" // add unique ID data = append([]byte("Message-ID: <"+messageID+">\r\n"), data...) } else if config.IgnoreDuplicateIDs { if storage.MessageIDExists(messageID) { logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID) stats.LogSMTPIgnored() return "", nil } } // if enabled, this may conditionally relay the email through to the preconfigured smtp server if relayErr := autoRelayMessage(from, to, &data); relayErr != nil { logger.Log().Error(relayErr.Error()) if config.SMTPRelayConfig.ForwardSMTPErrors { for { unwrappedErr := errors.Unwrap(relayErr) if unwrappedErr == nil { break } relayErr = unwrappedErr } return "", relayErr } } // if enabled, this will forward a copy to preconfigured addresses if forwardErr := autoForwardMessage(from, &data); forwardErr != nil { logger.Log().Error(forwardErr.Error()) if config.SMTPForwardConfig.ForwardSMTPErrors { for { unwrappedErr := errors.Unwrap(forwardErr) if unwrappedErr == nil { break } forwardErr = unwrappedErr } return "", forwardErr } } // build array of all addresses in the header to compare to the []to array emails, hasBccHeader := scanAddressesInHeader(msg.Header) missingAddresses := []string{} for _, a := range to { // loop through passed email addresses to check if they are in the headers if _, err := mail.ParseAddress(a); err == nil { _, ok := emails[strings.ToLower(a)] if !ok { missingAddresses = append(missingAddresses, a) } } else { logger.Log().Warnf("[smtpd] ignoring invalid email address: %s", a) } } // add missing email addresses to Bcc (eg: Laravel doesn't include these in the headers) if len(missingAddresses) > 0 { bccVal := strings.Join(missingAddresses, ", ") if hasBccHeader { b := msg.Header.Get("Bcc") bccVal = ", " + b } data, err = tools.SetMessageHeader(data, "Bcc", bccVal) if err != nil { return "", err } logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", ")) } id, err := storage.Store(&data, smtpUser) if err != nil { logger.Log().Errorf("[db] error storing message: %s", err.Error()) return "", err } stats.LogSMTPAccepted(len(data)) data = nil // avoid memory leaks subject := msg.Header.Get("Subject") logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject) return id, err } func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) { allow := auth.SMTPCredentials.Match(string(username), string(password)) if allow { logger.Log().Debugf("[smtpd] allow %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr)) } else { logger.Log().Warnf("[smtpd] deny %s login:%q from:%s", mechanism, string(username), cleanIP(remoteAddr)) } return allow, nil } // Allow any username and password func authHandlerAny(remoteAddr net.Addr, mechanism string, username []byte, _ []byte, _ []byte) (bool, error) { logger.Log().Debugf("[smtpd] allow %s login %q from %s", mechanism, string(username), cleanIP(remoteAddr)) return true, nil } // HandlerRcpt used to optionally restrict recipients based on `--smtp-allowed-recipients` func handlerRcpt(remoteAddr net.Addr, from string, to string) bool { if config.SMTPAllowedRecipientsRegexp == nil { return true } result := config.SMTPAllowedRecipientsRegexp.MatchString(to) if !result { logger.Log().Warnf("[smtpd] rejected message to %s from %s (%s)", to, from, cleanIP(remoteAddr)) stats.LogSMTPRejected() } return result } // Listen starts the SMTPD server func Listen() error { if config.SMTPAuthAllowInsecure { if auth.SMTPCredentials != nil { logger.Log().Info("[smtpd] enabling login authentication (insecure)") } else if config.SMTPAuthAcceptAny { logger.Log().Info("[smtpd] enabling any authentication (insecure)") } } else { if auth.SMTPCredentials != nil { logger.Log().Info("[smtpd] enabling login authentication") } else if config.SMTPAuthAcceptAny { logger.Log().Info("[smtpd] enabling any authentication") } } return listenAndServe(config.SMTPListen, mailHandler, authHandler) } // Translate the smtpd verb from READ/WRITE func verbLogTranslator(verb string) string { if verb == "READ" { return "received" } return "response" } func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) error { socketAddr, perm, isSocket := tools.UnixSocket(addr) Debug = true // to enable Mailpit logging srv := &Server{ Addr: addr, MsgIDHandler: handler, HandlerRcpt: handlerRcpt, AppName: "Mailpit", Hostname: "", AuthHandler: nil, AuthRequired: false, MaxRecipients: config.SMTPMaxRecipients, IgnoreRejectedRecipients: config.SMTPIgnoreRejectedRecipients, DisableReverseDNS: DisableReverseDNS, LogRead: func(remoteIP, verb, line string) { logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line) }, LogWrite: func(remoteIP, verb, line string) { if warningResponse.MatchString(line) { logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line) websockets.BroadCastClientError("warning", "smtpd", remoteIP, line) } else if errorResponse.MatchString(line) { logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line) websockets.BroadCastClientError("error", "smtpd", remoteIP, line) } else { logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line) } }, } if config.Label != "" { srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label) } if config.SMTPAuthAllowInsecure { srv.AuthMechs = map[string]bool{ "CRAM-MD5": false, "PLAIN": true, "LOGIN": true, } } if auth.SMTPCredentials != nil { srv.AuthMechs = map[string]bool{ "CRAM-MD5": false, "PLAIN": true, "LOGIN": true, } srv.AuthHandler = authHandler srv.AuthRequired = true } else if config.SMTPAuthAcceptAny { srv.AuthMechs = map[string]bool{ "CRAM-MD5": false, "PLAIN": true, "LOGIN": true, } srv.AuthHandler = authHandlerAny } if config.SMTPTLSCert != "" { srv.TLSRequired = config.SMTPRequireSTARTTLS srv.TLSListener = config.SMTPRequireTLS // if true overrules srv.TLSRequired if err := srv.ConfigureTLS(config.SMTPTLSCert, config.SMTPTLSKey); err != nil { return err } } if isSocket { srv.Addr = socketAddr srv.Protocol = "unix" srv.SocketPerm = perm if err := tools.PrepareSocket(srv.Addr); err != nil { storage.Close() return err } // delete the Unix socket file on exit storage.AddTempFile(srv.Addr) logger.Log().Infof("[smtpd] starting on %s", config.SMTPListen) } else { smtpType := "no encryption" if config.SMTPTLSCert != "" { if config.SMTPRequireTLS { smtpType = "SSL/TLS required" } else if config.SMTPRequireSTARTTLS { smtpType = "STARTTLS required" } else { smtpType = "STARTTLS optional" if !config.SMTPAuthAllowInsecure && auth.SMTPCredentials != nil { smtpType = "STARTTLS required" } } } logger.Log().Infof("[smtpd] starting on %s (%s)", config.SMTPListen, smtpType) } return srv.ListenAndServe() } func cleanIP(i net.Addr) string { parts := strings.Split(i.String(), ":") return parts[0] } // Returns a list of all lowercased emails found in To, Cc and Bcc, // as well as whether there is a Bcc field func scanAddressesInHeader(h mail.Header) (map[string]bool, bool) { emails := make(map[string]bool) hasBccHeader := false if recipients, err := h.AddressList("To"); err == nil { for _, r := range recipients { emails[strings.ToLower(r.Address)] = true } } if recipients, err := h.AddressList("Cc"); err == nil { for _, r := range recipients { emails[strings.ToLower(r.Address)] = true } } recipients, err := h.AddressList("Bcc") if err == nil { for _, r := range recipients { emails[strings.ToLower(r.Address)] = true } hasBccHeader = true } return emails, hasBccHeader } ================================================ FILE: internal/smtpd/relay.go ================================================ package smtpd import ( "crypto/tls" "fmt" "net/smtp" "os" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/pkg/errors" ) // Wrapper to auto relay messages if configured func autoRelayMessage(from string, to []string, data *[]byte) error { if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil { filteredTo := []string{} for _, address := range to { if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) { logger.Log().Debugf("[relay] ignoring auto-relay to %s: found in blocklist", address) continue } filteredTo = append(filteredTo, address) } to = filteredTo } if len(to) == 0 { return nil } if config.SMTPRelayAll { if err := Relay(from, to, *data); err != nil { return errors.WithMessage(err, "[relay] error") } logger.Log().Debugf( "[relay] sent message to %s from %s via %s:%d", strings.Join(to, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port, ) } else if config.SMTPRelayMatchingRegexp != nil { filtered := []string{} for _, t := range to { if config.SMTPRelayMatchingRegexp.MatchString(t) { filtered = append(filtered, t) } } if len(filtered) == 0 { return nil } if err := Relay(from, filtered, *data); err != nil { return errors.WithMessage(err, "[relay] error") } logger.Log().Debugf( "[relay] auto-relay message to %s from %s via %s:%d", strings.Join(filtered, ", "), from, config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port, ) } return nil } func createRelaySMTPClient(config config.SMTPRelayConfigStruct, addr string) (*smtp.Client, error) { if config.TLS { tlsConf := &tls.Config{ServerName: config.Host} // #nosec tlsConf.InsecureSkipVerify = config.AllowInsecure conn, err := tls.Dial("tcp", addr, tlsConf) if err != nil { return nil, fmt.Errorf("TLS dial error: %v", err) } client, err := smtp.NewClient(conn, tlsConf.ServerName) if err != nil { _ = conn.Close() return nil, fmt.Errorf("SMTP client error: %v", err) } // Note: The caller is responsible for closing the client return client, nil } client, err := smtp.Dial(addr) if err != nil { return nil, fmt.Errorf("error connecting to %s: %v", addr, err) } // Set the hostname for HELO/EHLO if hostname, err := os.Hostname(); err == nil { if err := client.Hello(hostname); err != nil { return nil, fmt.Errorf("error saying HELO/EHLO to %s: %v", addr, err) } } if config.STARTTLS { tlsConf := &tls.Config{ServerName: config.Host} // #nosec tlsConf.InsecureSkipVerify = config.AllowInsecure if err = client.StartTLS(tlsConf); err != nil { _ = client.Close() return nil, fmt.Errorf("error creating StartTLS config: %v", err) } } // Note: The caller is responsible for closing the client return client, nil } // Relay will connect to a pre-configured SMTP server and send a message to one or more recipients. func Relay(from string, to []string, msg []byte) error { addr := fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port) c, err := createRelaySMTPClient(config.SMTPRelayConfig, addr) if err != nil { return err } defer func() { _ = c.Close() }() auth := relayAuthFromConfig() if auth != nil { if err = c.Auth(auth); err != nil { return fmt.Errorf("error response to AUTH command: %s", err.Error()) } } if config.SMTPRelayConfig.OverrideFrom != "" { msg, err = tools.OverrideFromHeader(msg, config.SMTPRelayConfig.OverrideFrom) if err != nil { return fmt.Errorf("error overriding From header: %s", err.Error()) } from = config.SMTPRelayConfig.OverrideFrom } if err = c.Mail(from); err != nil { return errors.WithMessage(err, "error sending MAIL command") } for _, addr := range to { if err = c.Rcpt(addr); err != nil { logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error()) if config.SMTPRelayConfig.ForwardSMTPErrors { return errors.WithMessagef(err, "error response to RCPT command for %s", addr) } } } w, err := c.Data() if err != nil { return errors.WithMessage(err, "error response to DATA command") } if _, err := w.Write(msg); err != nil { return errors.WithMessage(err, "error sending message") } if err := w.Close(); err != nil { return errors.WithMessage(err, "error closing connection") } return c.Quit() } // Return the SMTP relay authentication based on config func relayAuthFromConfig() smtp.Auth { var a smtp.Auth if config.SMTPRelayConfig.Auth == "plain" { a = smtp.PlainAuth("", config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password, config.SMTPRelayConfig.Host) } if config.SMTPRelayConfig.Auth == "login" { a = LoginAuth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Password) } if config.SMTPRelayConfig.Auth == "cram-md5" { a = smtp.CRAMMD5Auth(config.SMTPRelayConfig.Username, config.SMTPRelayConfig.Secret) } return a } // Custom implementation of LOGIN SMTP authentication // @see https://gist.github.com/andelf/5118732 type loginAuth struct { username, password string } // LoginAuth authentication func LoginAuth(username, password string) smtp.Auth { return &loginAuth{ username, password, } } func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", []byte{}, nil } func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { if more { switch string(fromServer) { case "Username:": return []byte(a.username), nil case "Password:": return []byte(a.password), nil default: return nil, errors.New("unknown fromServer") } } return nil, nil } ================================================ FILE: internal/smtpd/smtpd.go ================================================ // Package smtpd implements a basic SMTP server. // // This is a modified version of https://github.com/mhale/smtpd to // add support for unix sockets and Mailpit Chaos. package smtpd import ( "bufio" "bytes" "context" "crypto/tls" "encoding/base64" "errors" "fmt" "io/fs" "log" "net" "net/mail" "os" "regexp" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/axllent/mailpit/internal/smtpd/chaos" ) var ( // Debug `true` enables verbose logging. Debug = false rcptToRE = regexp.MustCompile(`(?i)TO: ?<([^<>\v]+)>( |$)(.*)?`) mailFromRE = regexp.MustCompile(`(?i)FROM: ?<(|[^<>\v]+)>( |$)(.*)?`) // Delivery Status Notifications are sent with "MAIL FROM:<>" // extract mail size from 'MAIL FROM' parameter mailFromSizeRE = regexp.MustCompile(`(?U)(^| |,)[Ss][Ii][Zz][Ee]=(.*)($|,| )`) ) // Handler function called upon successful receipt of an email. // Results in a "250 2.0.0 Ok: queued" response. type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) error // MsgIDHandler function called upon successful receipt of an email. Returns a message ID. // Results in a "250 2.0.0 Ok: queued as " response. type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) // HandlerRcpt function called on RCPT. Return accept status. type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool // AuthHandler function called when a login attempt is performed. Returns true if credentials are correct. type AuthHandler func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) // ErrServerClosed is the default message when a server closes a connection var ErrServerClosed = errors.New("Server has been closed") // ListenAndServe listens on the TCP network address addr // and then calls Serve with handler to handle requests // on incoming connections. func ListenAndServe(addr string, handler Handler, appName string, hostname string) error { srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname} return srv.ListenAndServe() } // ListenAndServeTLS listens on the TCP network address addr // and then calls Serve with handler to handle requests // on incoming connections. Connections may be upgraded to TLS if the client requests it. func ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler, appName string, hostname string) error { srv := &Server{Addr: addr, Handler: handler, AppName: appName, Hostname: hostname} err := srv.ConfigureTLS(certFile, keyFile) if err != nil { return err } return srv.ListenAndServe() } type maxSizeExceededError struct { limit int } func maxSizeExceeded(limit int) maxSizeExceededError { return maxSizeExceededError{limit} } // Error uses the RFC 5321 response message in preference to RFC 1870. // RFC 3463 defines enhanced status code x.3.4 as "Message too big for system". func (err maxSizeExceededError) Error() string { return fmt.Sprintf("552 5.3.4 Requested mail action aborted: exceeded storage allocation (%d)", err.limit) } // LogFunc is a function capable of logging the client-server communication. type LogFunc func(remoteIP, verb, line string) // Server is an SMTP server. type Server struct { Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty AppName string AuthHandler AuthHandler AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance. AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured. DisableReverseDNS bool // Disable reverse DNS lookups, enforces "unknown" hostname Handler Handler HandlerRcpt HandlerRcpt Hostname string LogRead LogFunc LogWrite LogFunc MaxSize int // Maximum message size allowed, in bytes MaxRecipients int // Maximum number of recipients, defaults to 100. MsgIDHandler MsgIDHandler IgnoreRejectedRecipients bool // Accept emails to rejected recipients with 2xx response but silently drop them Timeout time.Duration TLSConfig *tls.Config TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured. TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured. Protocol string // Default tcp, supports unix SocketPerm fs.FileMode // if using Unix socket, socket permissions inShutdown int32 // server was closed or shutdown openSessions int32 // count of open sessions mu sync.Mutex shutdownChan chan struct{} // let the sessions know we are shutting down XClientAllowed []string // List of XCLIENT allowed IP addresses } // ConfigureTLS creates a TLS configuration from certificate and key files. func (srv *Server) ConfigureTLS(certFile string, keyFile string) error { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return err } srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} // #nosec return nil } // // ConfigureTLSWithPassphrase creates a TLS configuration from a certificate, // // an encrypted key file and the associated passphrase: // func (srv *Server) ConfigureTLSWithPassphrase( // certFile string, // keyFile string, // passphrase string, // ) error { // certPEMBlock, err := os.ReadFile(certFile) // if err != nil { // return err // } // keyPEMBlock, err := os.ReadFile(keyFile) // if err != nil { // return err // } // keyDERBlock, _ := pem.Decode(keyPEMBlock) // keyPEMDecrypted, err := x509.DecryptPEMBlock(keyDERBlock, []byte(passphrase)) // if err != nil { // return err // } // var pemBlock pem.Block // pemBlock.Type = keyDERBlock.Type // pemBlock.Bytes = keyPEMDecrypted // keyPEMBlock = pem.EncodeToMemory(&pemBlock) // cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) // if err != nil { // return err // } // srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} // return nil // } // ListenAndServe listens on the either a TCP network address srv.Addr or // alternatively a Unix socket. and then calls Serve to handle requests on // incoming connections. If srv.Addr is blank, ":25" is used. func (srv *Server) ListenAndServe() error { if atomic.LoadInt32(&srv.inShutdown) != 0 { return ErrServerClosed } if srv.Addr == "" { srv.Addr = ":25" } if srv.AppName == "" { srv.AppName = "smtpd" } if srv.Hostname == "" { srv.Hostname, _ = os.Hostname() } if srv.Timeout == 0 { srv.Timeout = 5 * time.Minute } if srv.Protocol == "" { srv.Protocol = "tcp" } var ln net.Listener var err error // If TLSListener is enabled, listen for TLS connections only. if srv.TLSConfig != nil && srv.TLSListener { ln, err = tls.Listen(srv.Protocol, srv.Addr, srv.TLSConfig) } else { ln, err = net.Listen(srv.Protocol, srv.Addr) } if err != nil { return err } if srv.Protocol == "unix" { // set permissions if err := os.Chmod(srv.Addr, srv.SocketPerm); err != nil { return err } } return srv.Serve(ln) } // Serve creates a new SMTP session after a network connection is established. func (srv *Server) Serve(ln net.Listener) error { if atomic.LoadInt32(&srv.inShutdown) != 0 { return ErrServerClosed } defer func() { _ = ln.Close() }() for { // if we are shutting down, don't accept new connections select { case <-srv.getShutdownChan(): return ErrServerClosed default: } conn, err := ln.Accept() if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { continue } return err } session := srv.newSession(conn) atomic.AddInt32(&srv.openSessions, 1) go session.serve() } } type session struct { srv *Server conn net.Conn br *bufio.Reader bw *bufio.Writer remoteIP string // Remote IP address remoteHost string // Remote hostname according to reverse DNS lookup remoteName string // Remote hostname as supplied with EHLO xClient string // Information string as supplied with XCLIENT xClientADDR string // Information string as supplied with XCLIENT ADDR xClientNAME string // Information string as supplied with XCLIENT NAME xClientTrust bool // Trust XCLIENT from current IP address tls bool authenticated bool username *string // username, nil if not authenticated } // Create new session from connection. func (srv *Server) newSession(conn net.Conn) (s *session) { s = &session{ srv: srv, conn: conn, br: bufio.NewReader(conn), bw: bufio.NewWriter(conn), } // Get remote end info for the Received header. s.remoteIP, _, _ = net.SplitHostPort(s.conn.RemoteAddr().String()) if s.remoteIP == "" { s.remoteIP = "127.0.0.1" } if !s.srv.DisableReverseDNS { names, err := net.LookupAddr(s.remoteIP) if err == nil && len(names) > 0 { s.remoteHost = names[0] } else { s.remoteHost = "unknown" } } else { s.remoteHost = "unknown" } // Set tls = true if TLS is already in use. _, s.tls = s.conn.(*tls.Conn) for _, checkIP := range srv.XClientAllowed { if s.remoteIP == checkIP { s.xClientTrust = true } } return } func (srv *Server) getShutdownChan() <-chan struct{} { srv.mu.Lock() defer srv.mu.Unlock() if srv.shutdownChan == nil { srv.shutdownChan = make(chan struct{}) } return srv.shutdownChan } func (srv *Server) closeShutdownChan() { srv.mu.Lock() defer srv.mu.Unlock() if srv.shutdownChan == nil { srv.shutdownChan = make(chan struct{}) } select { case <-srv.shutdownChan: default: close(srv.shutdownChan) } } // Close - closes the connection without waiting func (srv *Server) Close() error { atomic.StoreInt32(&srv.inShutdown, 1) srv.closeShutdownChan() return nil } // Shutdown - waits for current sessions to complete before closing func (srv *Server) Shutdown(ctx context.Context) error { atomic.StoreInt32(&srv.inShutdown, 1) srv.closeShutdownChan() // wait for up to 30 seconds to allow the current sessions to // end timer := time.NewTimer(100 * time.Millisecond) defer timer.Stop() for range 300 { // wait for open sessions to close if atomic.LoadInt32(&srv.openSessions) == 0 { break } select { case <-timer.C: timer.Reset(100 * time.Millisecond) case <-ctx.Done(): return ctx.Err() default: } } return nil } // Function called to handle connection requests. func (s *session) serve() { defer atomic.AddInt32(&s.srv.openSessions, -1) // pass the connection into the defer function to ensure it is closed, // otherwise results in a 5s timeout for each connection defer func(c net.Conn) { _ = c.Close() }(s.conn) var gotEHLO bool var from string var gotFROM bool var to []string var hasRejectedRecipients bool var buffer bytes.Buffer // RFC 5321 specifies support for minimum of 100 recipients is required. if s.srv.MaxRecipients == 0 { s.srv.MaxRecipients = 100 } // Send banner. s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.AppName) loop: for { // Attempt to read a line from the socket. // On timeout, send a timeout message and return from serve(). // On error, assume the client has gone away i.e. return from serve(). line, err := s.readLine() if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName) } break } verb, args := s.parseLine(line) switch verb { case "HELO": s.remoteName = args s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName) // RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too. gotEHLO = true from = "" gotFROM = false to = nil hasRejectedRecipients = false buffer.Reset() case "EHLO": s.remoteName = args s.writef("%s", s.makeEHLOResponse()) // RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET. gotEHLO = true from = "" gotFROM = false to = nil hasRejectedRecipients = false buffer.Reset() case "MAIL": if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { s.writef("530 5.7.0 Must issue a STARTTLS command first") break } if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated { s.writef("530 5.7.0 Authentication required") break } if !gotEHLO { s.writef("503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)") break } if to != nil { s.writef("503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)") break } match, err := extractAndValidateAddress(mailFromRE, args) if match == nil { if err != nil { s.writef("%s", err.Error()) } else { s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)") } } else { // Mailpit Chaos if fail, code := chaos.Config.Sender.Trigger(); fail { s.writef("%d Chaos sender error", code) break } // Validate the SIZE parameter if one was sent. if len(match[2]) > 0 { // A parameter is present sizeMatch := mailFromSizeRE.FindStringSubmatch(match[3]) if sizeMatch == nil { // ignore other parameter from = match[1] gotFROM = true s.writef("250 2.1.0 Ok") } else { // Enforce the maximum message size if one is set. size, err := strconv.Atoi(sizeMatch[2]) if err != nil { // Bad SIZE parameter s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)") } else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set err = maxSizeExceeded(s.srv.MaxSize) s.writef("%s", err.Error()) } else { // SIZE ok from = match[1] gotFROM = true s.writef("250 2.1.0 Ok") } } } else { // No parameters after FROM from = match[1] gotFROM = true s.writef("250 2.1.0 Ok") } } to = nil hasRejectedRecipients = false buffer.Reset() case "RCPT": if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { s.writef("530 5.7.0 Must issue a STARTTLS command first") break } if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated { s.writef("530 5.7.0 Authentication required") break } if !gotFROM { s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)") break } match, err := extractAndValidateAddress(rcptToRE, args) if match == nil { if err != nil { s.writef("%s", err.Error()) } else { s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)") } } else { // Mailpit Chaos if fail, code := chaos.Config.Recipient.Trigger(); fail { s.writef("%d Chaos recipient error", code) break } if len(to) >= s.srv.MaxRecipients { s.writef("452 4.5.3 Too many recipients") } else { accept := true if s.srv.HandlerRcpt != nil { accept = s.srv.HandlerRcpt(s.conn.RemoteAddr(), from, match[1]) } if accept { to = append(to, match[1]) s.writef("250 2.1.5 Ok") } else if s.srv.IgnoreRejectedRecipients { hasRejectedRecipients = true s.writef("250 2.1.5 Ok") } else { s.writef("550 5.1.0 Requested action not taken: mailbox unavailable") } } } case "DATA": if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { s.writef("530 5.7.0 Must issue a STARTTLS command first") break } if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated { s.writef("530 5.7.0 Authentication required") break } hasRecipients := len(to) > 0 || hasRejectedRecipients if !gotFROM || !hasRecipients { s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)") break } s.writef("354 Start mail input; end with .") // Attempt to read message body from the socket. // On timeout, send a timeout message and return from serve(). // On net.Error, assume the client has gone away i.e. return from serve(). // On other errors, allow the client to try again. data, err := s.readData() if err != nil { switch err := err.(type) { case net.Error: if err.Timeout() { s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName) } break loop case maxSizeExceededError: s.writef("%s", err.Error()) continue default: s.writef("451 4.3.0 Requested action aborted: local error in processing") continue } } // Create Received header & write message body into buffer. buffer.Reset() if len(to) > 0 { buffer.Write(s.makeHeaders(to)) } buffer.Write(data) // Pass mail on to handler only if there are valid recipients. if len(to) > 0 && s.srv.Handler != nil { err := s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes()) if err != nil { checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`) if checkErrFormat.MatchString(err.Error()) { s.writef("%s", err.Error()) } else { s.writef("451 4.3.5 Unable to process mail") } break } s.writef("250 2.0.0 Ok: queued") } else if len(to) > 0 && s.srv.MsgIDHandler != nil { msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username) if err != nil { checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`) if checkErrFormat.MatchString(err.Error()) { s.writef("%s", err.Error()) } else { s.writef("451 4.3.5 Unable to process mail") } break } if msgID != "" { s.writef("250 2.0.0 Ok: queued as %s", msgID) } else { s.writef("250 2.0.0 Ok: queued") } } else { if hasRejectedRecipients && Debug { if s.srv.LogWrite != nil { s.srv.LogWrite(s.remoteIP, "DEBUG", "Message from sender silently dropped (rejected recipients)") } else { log.Printf("%s DEBUG Message from sender silently dropped (rejected recipients)", s.remoteIP) } } s.writef("250 2.0.0 Ok: queued") } // Reset for next mail. from = "" gotFROM = false to = nil hasRejectedRecipients = false buffer.Reset() case "QUIT": s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.AppName) break loop case "RSET": if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { s.writef("530 5.7.0 Must issue a STARTTLS command first") break } s.writef("250 2.0.0 Ok") from = "" gotFROM = false to = nil hasRejectedRecipients = false buffer.Reset() case "NOOP": s.writef("250 2.0.0 Ok") case "XCLIENT": s.xClient = args if s.xClientTrust { xCArgs := strings.SplitSeq(args, " ") for xCArg := range xCArgs { xCParse := strings.Split(strings.TrimSpace(xCArg), "=") if strings.ToUpper(xCParse[0]) == "ADDR" && (net.ParseIP(xCParse[1]) != nil) { s.xClientADDR = xCParse[1] } if strings.ToUpper(xCParse[0]) == "NAME" && len(xCParse[1]) > 0 { if xCParse[1] != "[UNAVAILABLE]" { s.xClientNAME = xCParse[1] } } } if len(s.xClientADDR) > 7 { s.remoteIP = s.xClientADDR if len(s.xClientNAME) > 4 { s.remoteHost = s.xClientNAME } else { names, err := net.LookupAddr(s.remoteIP) if err == nil && len(names) > 0 { s.remoteHost = names[0] } else { s.remoteHost = "unknown" } } } } s.writef("250 2.0.0 Ok") case "HELP", "VRFY", "EXPN": // See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes. s.writef("502 5.5.1 Command not implemented") case "STARTTLS": // Parameters are not allowed (RFC 3207 section 4). if args != "" { s.writef("501 5.5.2 Syntax error (no parameters allowed)") break } // Handle case where TLS is requested but not configured (and therefore not listed as a service extension). if s.srv.TLSConfig == nil { s.writef("502 5.5.1 Command not implemented") break } // Handle case where STARTTLS is received when TLS is already in use. if s.tls { s.writef("503 5.5.1 Bad sequence of commands (TLS already in use)") break } s.writef("220 2.0.0 Ready to start TLS") // Establish a TLS connection with the client. tlsConn := tls.Server(s.conn, s.srv.TLSConfig) err := tlsConn.Handshake() if err != nil { s.writef("403 4.7.0 TLS handshake failed") break } // TLS handshake succeeded, switch to using the TLS connection. s.conn = tlsConn s.br = bufio.NewReader(s.conn) s.bw = bufio.NewWriter(s.conn) s.tls = true // RFC 3207 specifies that the server must discard any prior knowledge obtained from the client. s.remoteName = "" from = "" gotFROM = false to = nil hasRejectedRecipients = false buffer.Reset() case "AUTH": if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls { s.writef("530 5.7.0 Must issue a STARTTLS command first") break } // Handle case where AUTH is requested but not configured (and therefore not listed as a service extension). if s.srv.AuthHandler == nil { s.writef("502 5.5.1 Command not implemented") break } // Handle case where AUTH is received when already authenticated. if s.authenticated { s.writef("503 5.5.1 Bad sequence of commands (already authenticated for this session)") break } // RFC 4954 specifies that AUTH is not permitted during mail transactions. if gotFROM || len(to) > 0 { s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)") break } // RFC 4954 requires a mechanism parameter. authType, authArgs := s.parseLine(args) if authType == "" { s.writef("501 5.5.4 Malformed AUTH input (argument required)") break } // RFC 4954 requires rejecting unsupported authentication mechanisms with a 504 response. allowedAuth := s.authMechs() if allowed, found := allowedAuth[authType]; !found || !allowed { s.writef("504 5.5.4 Unrecognized authentication type") break } // Mailpit Chaos if fail, code := chaos.Config.Authentication.Trigger(); fail { s.writef("%d Chaos authentication error", code) break } // RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned // when attempting to use an unsupported authentication type. // Many servers return 5.7.4 ("Security features not supported") instead. switch authType { case "PLAIN": s.authenticated, err = s.handleAuthPlain(authArgs) case "LOGIN": s.authenticated, err = s.handleAuthLogin(authArgs) case "CRAM-MD5": s.authenticated, err = s.handleAuthCramMD5() } if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.AppName) break loop } s.writef("%s", err.Error()) break } if s.authenticated { s.writef("235 2.7.0 Authentication successful") } else { s.writef("535 5.7.8 Authentication credentials invalid") } default: // See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes. s.writef("500 5.5.2 Syntax error, command unrecognized") } } } // Wrapper function for writing a complete line to the socket. func (s *session) writef(format string, args ...any) { if s.srv.Timeout > 0 { _ = s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout)) } line := fmt.Sprintf(format, args...) _, _ = fmt.Fprintf(s.bw, "%s\r\n", line) _ = s.bw.Flush() if Debug { verb := "WROTE" if s.srv.LogWrite != nil { s.srv.LogWrite(s.remoteIP, verb, line) } else { log.Println(s.remoteIP, verb, line) } } } // Read a complete line from the socket. func (s *session) readLine() (string, error) { if s.srv.Timeout > 0 { _ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout)) } line, err := s.br.ReadString('\n') if err != nil { return "", err } line = strings.TrimSpace(line) // Strip trailing \r\n if Debug { verb := "READ" if s.srv.LogRead != nil { s.srv.LogRead(s.remoteIP, verb, line) } else { log.Println(s.remoteIP, verb, line) } } return line, err } // Parse a line read from the socket. func (s *session) parseLine(line string) (verb string, args string) { if before, after, ok := strings.Cut(line, " "); ok { verb = strings.ToUpper(before) args = strings.TrimSpace(after) } else { verb = strings.ToUpper(line) args = "" } return verb, args } // Read the message data following a DATA command. func (s *session) readData() ([]byte, error) { var data []byte for { if s.srv.Timeout > 0 { _ = s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout)) } line, err := s.br.ReadBytes('\n') if err != nil { return nil, err } // Handle end of data denoted by lone period (\r\n.\r\n) if bytes.Equal(line, []byte(".\r\n")) { break } // Remove leading period (RFC 5321 section 4.5.2) if line[0] == '.' { line = line[1:] } // Enforce the maximum message size limit. if s.srv.MaxSize > 0 { if len(data)+len(line) > s.srv.MaxSize { _, _ = s.br.Discard(s.br.Buffered()) // Discard the buffer remnants. return nil, maxSizeExceeded(s.srv.MaxSize) } } data = append(data, line...) } return data, nil } // Create the Received header to comply with RFC 2821 section 3.8.2. // TODO: Work out what to do with multiple to addresses. func (s *session) makeHeaders(to []string) []byte { var buffer bytes.Buffer now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)") buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP)) buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.AppName)) buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now)) return buffer.Bytes() } // Determine allowed authentication mechanisms. // RFC 4954 specifies that plaintext authentication mechanisms such as LOGIN and PLAIN require a TLS connection. // This can be explicitly overridden e.g. setting s.srv.AuthMechs["LOGIN"] = true. func (s *session) authMechs() (mechs map[string]bool) { mechs = map[string]bool{"LOGIN": s.tls, "PLAIN": s.tls, "CRAM-MD5": true} for mech := range mechs { allowed, found := s.srv.AuthMechs[mech] if found { mechs[mech] = allowed } } return } // Create the greeting string sent in response to an EHLO command. func (s *session) makeEHLOResponse() (response string) { response = fmt.Sprintf("250-%s greets %s\r\n", s.srv.Hostname, s.remoteName) // RFC 1870 specifies that "SIZE 0" indicates no maximum size is in force. response += fmt.Sprintf("250-SIZE %d\r\n", s.srv.MaxSize) // Only list STARTTLS if TLS is configured, but not currently in use. if s.srv.TLSConfig != nil && !s.tls { response += "250-STARTTLS\r\n" } // Only list AUTH if an AuthHandler is configured and at least one mechanism is allowed. if s.srv.AuthHandler != nil { var mechs []string for mech, allowed := range s.authMechs() { if allowed { mechs = append(mechs, mech) } } if len(mechs) > 0 { response += "250-AUTH " + strings.Join(mechs, " ") + "\r\n" } } response += "250-ENHANCEDSTATUSCODES\r\n" // RFC 6531 specifies that the presence of SMTPUTF8 should include 8BITMIME // "Servers offering this extension MUST provide support for, and announce, the 8BITMIME extension" // https://www.rfc-editor.org/rfc/rfc6531#section-3.1: response += "250-8BITMIME\r\n" response += "250 SMTPUTF8" // last entry must use a space instead of a dash return } func (s *session) handleAuthLogin(arg string) (bool, error) { var err error if arg == "" { s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Username:"))) arg, err = s.readLine() if err != nil { return false, err } } username, err := base64.StdEncoding.DecodeString(arg) if err != nil { return false, errors.New("501 5.5.2 Syntax error (unable to decode)") } s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte("Password:"))) line, err := s.readLine() if err != nil { return false, err } password, err := base64.StdEncoding.DecodeString(line) if err != nil { return false, errors.New("501 5.5.2 Syntax error (unable to decode)") } // Validate credentials. authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil) if authenticated { uname := string(username) s.username = &uname } else { s.username = nil } return authenticated, err } func (s *session) handleAuthPlain(arg string) (bool, error) { var err error // If fast mode (AUTH PLAIN [arg]) is not used, prompt for credentials. if arg == "" { s.writef("334 ") arg, err = s.readLine() if err != nil { return false, err } } data, err := base64.StdEncoding.DecodeString(arg) if err != nil { return false, errors.New("501 5.5.2 Syntax error (unable to decode)") } parts := bytes.Split(data, []byte{0}) if len(parts) != 3 { return false, errors.New("501 5.5.2 Syntax error (unable to parse)") } // Validate credentials. authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil) if authenticated { uname := string(parts[1]) s.username = &uname } else { s.username = nil } return authenticated, err } func (s *session) handleAuthCramMD5() (bool, error) { shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">" s.writef("334 %s", base64.StdEncoding.EncodeToString([]byte(shared))) data, err := s.readLine() if err != nil { return false, err } if data == "*" { return false, errors.New("501 5.7.0 Authentication cancelled") } buf, err := base64.StdEncoding.DecodeString(data) if err != nil { return false, errors.New("501 5.5.2 Syntax error (unable to decode)") } fields := strings.Split(string(buf), " ") if len(fields) < 2 { return false, errors.New("501 5.5.2 Syntax error (unable to parse)") } // Validate credentials. authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "CRAM-MD5", []byte(fields[0]), []byte(fields[1]), []byte(shared)) return authenticated, err } // Extract and validate email address from a regex match. // This ensures that only RFC 5322 compliant email addresses are accepted (if set). func extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) { match := re.FindStringSubmatch(args) if match == nil { return nil, nil } if strings.Contains(match[1], " ") { return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address") } // first argument will be the email address, validate it if not empty if match[1] != "" { a, err := mail.ParseAddress(match[1]) if err != nil { return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address") } // https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1 // RFC states that the local part of an email address SHOULD not exceed 64 characters // and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620 // it appears that investigated mail servers do not actually implement this limit, but rather enforce // a much larger limit (ie: 1024 characters). if len(a.Address) > 1024 { return nil, errors.New("500 The address is too long") } } return match, nil } ================================================ FILE: internal/smtpd/smtpd_test.go ================================================ package smtpd import ( "bufio" "bytes" "context" "crypto/hmac" "crypto/md5" "crypto/tls" "encoding/base64" "errors" "fmt" "io" "net" "reflect" "regexp" "strings" "testing" "time" ) var cert = makeCertificate() // Create a client to run commands with. Parse the banner for 220 response. func newConn(t *testing.T, server *Server) net.Conn { clientConn, serverConn := net.Pipe() session := server.newSession(serverConn) go session.serve() banner, err := bufio.NewReader(clientConn).ReadString('\n') if err != nil { t.Fatalf("Failed to read banner from test server: %v", err) } if banner[0:3] != "220" { t.Fatalf("Read incorrect banner from test server: %v", banner) } return clientConn } // Send a command and verify the 3 digit code from the response. func cmdCode(t *testing.T, conn net.Conn, cmd string, code string) string { _, _ = fmt.Fprintf(conn, "%s\r\n", cmd) resp, err := bufio.NewReader(conn).ReadString('\n') if err != nil { t.Fatalf("Failed to read response from test server: %v", err) } if resp[0:3] != code { t.Errorf("Command \"%s\" response code is %s, want %s", cmd, resp[0:3], code) } return strings.TrimSpace(resp) } // Simple tests: connect, send command, then send QUIT. // RFC 2821 section 4.1.4 specifies that these commands do not require a prior EHLO, // only that clients should send one, so test without EHLO. func TestSimpleCommands(t *testing.T) { tests := []struct { cmd string code string }{ {"NOOP", "250"}, {"RSET", "250"}, {"HELP", "502"}, {"VRFY", "502"}, {"EXPN", "502"}, {"TEST", "500"}, // Unsupported command {"", "500"}, // Blank command } for _, tt := range tests { conn := newConn(t, &Server{}) cmdCode(t, conn, tt.cmd, tt.code) cmdCode(t, conn, "QUIT", "221") if err := conn.Close(); err != nil { t.Errorf("Failed to close connection after command %s: %v", tt.cmd, err) } } } func TestCmdHELO(t *testing.T) { conn := newConn(t, &Server{}) // Send HELO, expect greeting. cmdCode(t, conn, "HELO host.example.com", "250") // Verify that HELO resets the current transaction state like RSET. // RFC 2821 section 4.1.4 says EHLO should cause a reset, so verify that HELO does it too. cmdCode(t, conn, "mail from:", "250") // Also testing case-insensitivity cmdCode(t, conn, "rcpt to:", "250") cmdCode(t, conn, "HELO host.example.com", "250") cmdCode(t, conn, "DATA", "503") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdEHLO(t *testing.T) { conn := newConn(t, &Server{}) // Send EHLO, expect greeting. cmdCode(t, conn, "EHLO host.example.com", "250") // Verify that EHLO resets the current transaction state like RSET. // See RFC 2821 section 4.1.4 for more detail. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") // test invalid addresses & header injection cmdCode(t, conn, "RCPT TO: ", "500") // too long cmdCode(t, conn, "RCPT TO:", "553") cmdCode(t, conn, "RCPT TO: ", "553") cmdCode(t, conn, "RCPT TO: ", "553") cmdCode(t, conn, "RCPT TO: ", "553") cmdCode(t, conn, "RCPT TO: ", "501") cmdCode(t, conn, "RCPT TO:", "553") cmdCode(t, conn, "RCPT TO: ", "553") cmdCode(t, conn, "RCPT TO: ", "501") cmdCode(t, conn, "RCPT TO: <>", "501") // empty address not allowed here cmdCode(t, conn, "EHLO host.example.com", "250") cmdCode(t, conn, "DATA", "503") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdMAILBeforeEHLO(t *testing.T) { conn := newConn(t, &Server{}) // RFC 5321 §4.1.4 — Order of Commands states (emphasis added): // “The SMTP client MUST issue HELO or EHLO before any other SMTP commands.” cmdCode(t, conn, "MAIL FROM:", "503") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdMAILAfterRCPT(t *testing.T) { conn := newConn(t, &Server{}) // Send EHLO, expect greeting cmdCode(t, conn, "EHLO host.example.com", "250") // Send MAIL FROM cmdCode(t, conn, "MAIL FROM:", "250") // Send RCPT TO cmdCode(t, conn, "RCPT TO:", "250") // MAIL FROM must not come after RCPT TO in the same transaction cmdCode(t, conn, "MAIL FROM:", "503") // RSET to clear the transaction cmdCode(t, conn, "RSET", "250") // Now the MAIL FROM should be accepted cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdRSET(t *testing.T) { conn := newConn(t, &Server{}) cmdCode(t, conn, "EHLO host.example.com", "250") // Verify that RSET clears the current transaction state. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "DATA", "503") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdMAIL(t *testing.T) { conn := newConn(t, &Server{}) cmdCode(t, conn, "EHLO host.example.com", "250") // MAIL with no FROM arg should return 501 syntax error cmdCode(t, conn, "MAIL", "501") // // MAIL with empty FROM arg should return 501 syntax error cmdCode(t, conn, "MAIL FROM:", "501") cmdCode(t, conn, "MAIL FROM: ", "501") cmdCode(t, conn, "MAIL FROM: ", "501") // MAIL with DSN-style FROM arg should return 250 Ok cmdCode(t, conn, "MAIL FROM:<>", "250") // MAIL with valid FROM arg should return 250 Ok cmdCode(t, conn, "MAIL FROM:", "250") // MAIL with seemingly valid but noncompliant FROM arg (single space after the colon) should be tolerated and should return 250 Ok cmdCode(t, conn, "MAIL FROM: ", "250") // MAIL with seemingly valid but noncompliant FROM arg (double space after the colon) should return 501 syntax error cmdCode(t, conn, "MAIL FROM: ", "501") // test invalid addresses & header injection cmdCode(t, conn, "MAIL FROM: ", "500") // too long cmdCode(t, conn, "MAIL FROM:", "553") cmdCode(t, conn, "MAIL FROM: ", "553") cmdCode(t, conn, "MAIL FROM: ", "501") cmdCode(t, conn, "MAIL FROM:", "553") cmdCode(t, conn, "MAIL FROM: ", "553") cmdCode(t, conn, "MAIL FROM: ", "553") cmdCode(t, conn, "MAIL FROM: ", "553") cmdCode(t, conn, "MAIL FROM: ", "501") cmdCode(t, conn, "MAIL FROM: < sender@example.com >", "553") cmdCode(t, conn, "MAIL FROM: < sender@example.com>", "553") cmdCode(t, conn, "MAIL FROM: ", "553") // MAIL with valid SIZE parameter should return 250 Ok cmdCode(t, conn, "MAIL FROM: SIZE=1000", "250") // MAIL with bad size parameter should return 501 syntax error cmdCode(t, conn, "MAIL FROM: SIZE=", "501") cmdCode(t, conn, "MAIL FROM: SIZE= ", "501") cmdCode(t, conn, "MAIL FROM: SIZE=foo", "501") // MAIL with BODY parameter should be accepted (8BITMIME support) cmdCode(t, conn, "MAIL FROM: BODY=8BITMIME", "250") cmdCode(t, conn, "MAIL FROM: BODY=8BITMIME,SIZE=1000", "250") cmdCode(t, conn, "MAIL FROM: BODY=8BITMIME,SIZE=foo", "501") // SIZE validation error // TODO: MAIL with valid AUTH parameter should return 250 Ok // TODO: MAIL with invalid AUTH parameter must return 501 syntax error cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdMAILMaxSize(t *testing.T) { maxSize := 10 + time.Now().Minute() conn := newConn(t, &Server{MaxSize: maxSize}) cmdCode(t, conn, "EHLO host.example.com", "250") // MAIL with no size parameter should return 250 Ok cmdCode(t, conn, "MAIL FROM:", "250") // MAIL with bad size parameter should return 501 syntax error cmdCode(t, conn, "MAIL FROM: SIZE=", "501") cmdCode(t, conn, "MAIL FROM: SIZE= ", "501") cmdCode(t, conn, "MAIL FROM: SIZE=foo", "501") // MAIL with size parameter zero should return 250 Ok cmdCode(t, conn, "MAIL FROM: SIZE=0", "250") // MAIL below the maximum size should return 250 Ok cmdCode(t, conn, fmt.Sprintf("MAIL FROM: SIZE=%d", maxSize-1), "250") // MAIL matching the maximum size should return 250 Ok cmdCode(t, conn, fmt.Sprintf("MAIL FROM: SIZE=%d", maxSize), "250") // MAIL above the maximum size should return a maximum size exceeded error. cmdCode(t, conn, fmt.Sprintf("MAIL FROM: SIZE=%d", maxSize+1), "552") // Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2). cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdRCPT(t *testing.T) { conn := newConn(t, &Server{}) cmdCode(t, conn, "EHLO host.example.com", "250") // RCPT without prior MAIL should return 503 bad sequence cmdCode(t, conn, "RCPT", "503") cmdCode(t, conn, "MAIL FROM:", "250") // RCPT with no TO arg should return 501 syntax error cmdCode(t, conn, "RCPT", "501") // RCPT with empty TO arg should return 501 syntax error cmdCode(t, conn, "RCPT TO:", "501") cmdCode(t, conn, "RCPT TO: ", "501") cmdCode(t, conn, "RCPT TO: ", "501") cmdCode(t, conn, "RCPT TO:<@route.example user@example.com>", "553") // RCPT with valid TO arg should return 250 Ok cmdCode(t, conn, "RCPT TO:", "250") // Up to 100 valid recipients should return 250 Ok for i := 2; i < 101; i++ { cmdCode(t, conn, fmt.Sprintf("RCPT TO:", i), "250") } // 101st valid recipient with valid TO arg should return 452 too many recipients cmdCode(t, conn, "RCPT TO:", "452") // RCPT with valid TO arg and prior DSN-style FROM arg should return 250 Ok cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "MAIL FROM:<>", "250") cmdCode(t, conn, "RCPT TO:", "250") // RCPT with seemingly valid but noncompliant TO arg (single space after the colon) should be tolerated and should return 250 Ok cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "MAIL FROM:<>", "250") cmdCode(t, conn, "RCPT TO: ", "250") // RCPT with seemingly valid but noncompliant TO arg (double space after the colon) should return 501 syntax error cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "MAIL FROM:<>", "250") cmdCode(t, conn, "RCPT TO: ", "501") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdMaxRecipients(t *testing.T) { conn := newConn(t, &Server{MaxRecipients: 3}) cmdCode(t, conn, "EHLO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO: ", "250") cmdCode(t, conn, "RCPT TO: ", "250") cmdCode(t, conn, "RCPT TO: ", "250") cmdCode(t, conn, "RCPT TO: ", "452") cmdCode(t, conn, "RCPT TO: ", "452") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdDATA(t *testing.T) { conn := newConn(t, &Server{}) cmdCode(t, conn, "EHLO host.example.com", "250") // DATA without prior MAIL & RCPT should return 503 bad sequence cmdCode(t, conn, "DATA", "503") cmdCode(t, conn, "RSET", "250") // DATA without prior RCPT should return 503 bad sequence cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "DATA", "503") cmdCode(t, conn, "RSET", "250") // Test a full mail transaction. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\n.", "250") // Test a full mail transaction with a bad last recipient. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "RCPT TO:", "501") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\n.", "250") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdDATAWithMaxSize(t *testing.T) { // "Test message.\r\n." is 15 bytes after trailing period is removed. conn := newConn(t, &Server{MaxSize: 15}) cmdCode(t, conn, "EHLO host.example.com", "250") // Messages below the maximum size should return 250 Ok cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message\r\n.", "250") // Messages matching the maximum size should return 250 Ok cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\n.", "250") // Messages above the maximum size should return a maximum size exceeded error. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message that is too long.\r\n.", "552") // Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2). cmdCode(t, conn, "RSET", "250") // Messages above the maximum size should return a maximum size exceeded error. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\nSecond line that is too long.\r\n.", "552") // Clients should send either RSET or QUIT after receiving 552 (RFC 1870 section 6.2). cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } type mockHandler struct { handlerCalled int } func (m *mockHandler) handler(err error) func(a net.Addr, f string, t []string, d []byte) error { return func(a net.Addr, f string, t []string, d []byte) error { m.handlerCalled++ return err } } func TestCmdDATAWithHandler(t *testing.T) { m := mockHandler{} conn := newConn(t, &Server{Handler: m.handler(nil)}) cmdCode(t, conn, "EHLO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\n.", "250") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() if m.handlerCalled != 1 { t.Errorf("MailHandler called %d times, want one call", m.handlerCalled) } } func TestCmdDATAWithHandlerError(t *testing.T) { m := mockHandler{} conn := newConn(t, &Server{Handler: m.handler(errors.New("Handler error"))}) cmdCode(t, conn, "EHLO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message.\r\n.", "451") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() if m.handlerCalled != 1 { t.Errorf("MailHandler called %d times, want one call", m.handlerCalled) } } func TestCmdSTARTTLS(t *testing.T) { conn := newConn(t, &Server{}) cmdCode(t, conn, "EHLO host.example.com", "250") // By default, TLS is not configured, so STARTTLS should return 502 not implemented. cmdCode(t, conn, "STARTTLS", "502") // Parameters are not allowed (RFC 3207 section 4). cmdCode(t, conn, "STARTTLS FOO", "501") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdSTARTTLSFailure(t *testing.T) { // Deliberately misconfigure TLS to force a handshake failure. server := &Server{TLSConfig: &tls.Config{}} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // When TLS is configured, STARTTLS should return 220 Ready to start TLS. cmdCode(t, conn, "STARTTLS", "220") // A failed TLS handshake should return 403 TLS handshake failed tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { reader := bufio.NewReader(conn) resp, readErr := reader.ReadString('\n') if readErr != nil { t.Fatalf("Failed to read response after failed TLS handshake: %v", err) } if resp[0:3] != "403" { t.Errorf("Failed TLS handshake response code is %s, want 403", resp[0:3]) } } else { t.Error("TLS handshake succeeded with empty tls.Config, want failure") } cmdCode(t, conn, "QUIT", "221") _ = tlsConn.Close() } // Utility function to make a valid TLS certificate for use by the server. func makeCertificate() tls.Certificate { const certPEM = ` -----BEGIN CERTIFICATE----- MIID9DCCAtygAwIBAgIJAIX/1sxuqZKrMA0GCSqGSIb3DQEBCwUAMFkxCzAJBgNV BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdDAeFw0xNzA1MDYxNDIy MjVaFw0yNzA1MDQxNDIyMjVaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21l LVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV BAMTCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALO4 XVY5Kw9eNblqBenC03Wz6qemLFw8zLDNrehvjYuJPn5WVwvzLNP+3S02iqQD+Y1k vszqDIZLQdjWLiEZdtxfemyIr+RePIMclnceGYFx3Zgg5qeyvOWlJLM41ZU8YZb/ zGj3RtXzuOZ5vePSLGS1nudjrKSBs7shRY8bYjkOqFujsSVnEK7s3Kb2Sf/rO+7N RZ1df3hhyKtyq4Pb5eC1mtQqcRjRSZdTxva8kO4vRQbvGgjLUakvBVrrnwbww5a4 2wKbQPKIClEbSLyKQ62zR8gW1rPwBdokd8u9+rLbcmr7l0OuAsSn5Xi9x6VxXTNE bgCa1KVoE4bpoGG+KQsCAwEAAaOBvjCBuzAdBgNVHQ4EFgQUILso/fozIhaoyi05 XNSWzP/ck+4wgYsGA1UdIwSBgzCBgIAUILso/fozIhaoyi05XNSWzP/ck+6hXaRb MFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJ bnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCWxvY2FsaG9zdIIJAIX/ 1sxuqZKrMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIbzsvTZb8LA JqyaTttsMMA1szf4WBX88lVWbIk91k0nlTa0BiU/UocKrU6c9PySwJ6FOFJpgpdH z/kmJ+S+d4pvgqBzWbKMoMrNlMt6vL+H8Mbf/l/CN91eNM+gJZu2HgBIFGW1y4Wy gOzjEm9bw15Hgqqs0P4CSy7jcelWA285DJ7IG1qdPGhAKxT4/UuDin8L/u2oeYWH 3DwTDO4kAUnKetcmNQFSX3Ge50uQypl8viYgFJ2axOfZ3imjQZrs7M1Og6Wnj/SD F414wVQibsZyZp8cqwR/OinvxloPkPVnf163jPRtftuqezEY8Nyj83O5u5sC1Azs X/Gm54QNk6w= -----END CERTIFICATE-----` const keyPEM = ` -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAs7hdVjkrD141uWoF6cLTdbPqp6YsXDzMsM2t6G+Ni4k+flZX C/Ms0/7dLTaKpAP5jWS+zOoMhktB2NYuIRl23F96bIiv5F48gxyWdx4ZgXHdmCDm p7K85aUkszjVlTxhlv/MaPdG1fO45nm949IsZLWe52OspIGzuyFFjxtiOQ6oW6Ox JWcQruzcpvZJ/+s77s1FnV1/eGHIq3Krg9vl4LWa1CpxGNFJl1PG9ryQ7i9FBu8a CMtRqS8FWuufBvDDlrjbAptA8ogKURtIvIpDrbNHyBbWs/AF2iR3y736sttyavuX Q64CxKfleL3HpXFdM0RuAJrUpWgThumgYb4pCwIDAQABAoIBAHzvYntJPKTvUhu2 F6w8kvHVBABNpbLtVUJniUj3G4fv/bCn5tVY1EX/e9QtgU2psbbYXUdoQRKuiHTr 15+M6zMhcKK4lsYDuL9QhU0DcKmq9WgHHzFfMK/YEN5CWT/ofNMSuhASLn0Xc+dM pHQWrGPKWk/y25Z0z/P7mjZ0y+BrJOKlxV53A2AWpj4JtjX2YO6s/eiraFX+RNlv GyWzeQ7Gynm2TD9VXhS+m40VVBmmbbeZYDlziDoWWNe9r26A+C8K65gZtjKdarMd 0LN89jJvI1pUxcIuvZJnumWUenZ7JhfBGpkfAwLB+MogUo9ekAHv1IZv/m3uWq9f Zml2dZECgYEA2OCI8kkLRa3+IodqQNFrb/uZ16YouQ71B7nBgAxls9nuhyELKO7d fzf1snPx6cbaCQKTyxrlYvck4gz8P09R7nVYwJuTmP0+QIgeCCc3Y9A2dyExaC6I uKkFzJEqIVZNLvdjBRWQs5AiD1w58oto+wOvbagAQM483WiJ/qFaHCMCgYEA1CPo zwI6pCn39RSYffK25HXM1q3i8ypkYdNsG6IVqS2FqHqj8XJSnDvLeIm7W1Rtw+uM QdZ5O6PH31XgolG6LrFkW9vtfH+QnXQA2AnZQEfn034YZubhcexLqAkS9r0FUUZp a1WI2jSxBBeB+to6MdNABuQOL3NHjPUidUKnOfkCgYA+HvKbE7ka2F+23DrfHh08 EkFat8lqWJJvCBIY73QiNAZSxnA/5UukqQ7DctqUL9U8R3S19JpH4qq55SZLrBi3 yP0HDokUhVVTfqm7hCAlgvpW3TcdtFaNLjzu/5WlvuaU0V+XkTnFdT+MTsp6YtxL Kh8RtdF8vpZIhS0htm3tKQKBgQDQXoUp79KRtPdsrtIpw+GI/Xw50Yp9tkHrJLOn YMlN5vzFw9CMM/KYqtLsjryMtJ0sN40IjhV+UxzbbYq7ZPMvMeaVo6vdAZ+WSH8b tHDEBtzai5yEVntSXvrhDiimWnuCnVqmptlJG0BT+JMfRoKqtgjJu++DBARfm9hA vTtsYQKBgE1ttTzd3HJoIhBBSvSMbyDWTED6jecKvsVypb7QeDxZCbIwCkoK9zn1 twPDHLBcUNhHJx6JWTR6BxI5DZoIA1tcKHtdO5smjLWNSKhXTsKWee2aNkZJkNIW TDHSaTMOxVUEzpx84xClf561BTiTgzQy2MULpg3AK0Cv9l0+Yrvz -----END RSA PRIVATE KEY-----` cert, _ := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) return cert } func TestCmdSTARTTLSSuccess(t *testing.T) { // Configure a valid TLS certificate so the handshake will succeed. server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // When TLS is configured, STARTTLS should return 220 Ready to start TLS. cmdCode(t, conn, "STARTTLS", "220") // A successful TLS handshake shouldn't return anything, it should wait for EHLO. tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } // The subsequent EHLO should be successful. cmdCode(t, tlsConn, "EHLO host.example.com", "250") // When TLS is already in use, STARTTLS should return 503 bad sequence. cmdCode(t, tlsConn, "STARTTLS", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdSTARTTLSRequired(t *testing.T) { tests := []struct { cmd string codeBefore string codeAfter string }{ {"EHLO host.example.com", "250", "250"}, {"NOOP", "250", "250"}, {"MAIL FROM:", "530", "250"}, {"RCPT TO:", "530", "250"}, {"RSET", "530", "250"}, // Reset before DATA to avoid having to actually send a message. {"DATA", "530", "503"}, {"HELP", "502", "502"}, {"VRFY", "502", "502"}, {"EXPN", "502", "502"}, {"TEST", "500", "500"}, // Unsupported command {"", "500", "500"}, // Blank command {"AUTH", "530", "502"}, // AuthHandler not configured } // If TLS is not configured, the TLSRequired setting is ignored, so it must be configured for this test. server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, TLSRequired: true} conn := newConn(t, server) // If TLS is required, but not in use, reject every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207 section 4. for _, tt := range tests { cmdCode(t, conn, tt.cmd, tt.codeBefore) } // Switch to using TLS. cmdCode(t, conn, "STARTTLS", "220") // A successful TLS handshake shouldn't return anything, it should wait for EHLO. tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } // The subsequent EHLO should be successful. cmdCode(t, tlsConn, "EHLO host.example.com", "250") // If TLS is required, and is in use, every command should work normally. for _, tt := range tests { cmdCode(t, tlsConn, tt.cmd, tt.codeAfter) } cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestMakeHeaders(t *testing.T) { now := time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700 (MST)") valid := "Received: from clientName (clientHost [clientIP])\r\n" + " by serverName (smtpd) with SMTP\r\n" + " for ; " + fmt.Sprintf("%s\r\n", now) srv := &Server{AppName: "smtpd", Hostname: "serverName"} s := &session{srv: srv, remoteIP: "clientIP", remoteHost: "clientHost", remoteName: "clientName"} headers := s.makeHeaders([]string{"recipient@example.com"}) if string(headers) != valid { t.Errorf("makeHeaders() returned\n%v, want\n%v", string(headers), valid) } } // Test parsing of commands into verbs and arguments. func TestParseLine(t *testing.T) { tests := []struct { line string verb string args string }{ {"EHLO host.example.com", "EHLO", "host.example.com"}, {"MAIL FROM:", "MAIL", "FROM:"}, {"RCPT TO:", "RCPT", "TO:"}, {"QUIT", "QUIT", ""}, } s := &session{} for _, tt := range tests { verb, args := s.parseLine(tt.line) if verb != tt.verb || args != tt.args { t.Errorf("ParseLine(%v) returned %v, %v, want %v, %v", tt.line, verb, args, tt.verb, tt.args) } } } // Test reading of complete lines from the socket. func TestReadLine(t *testing.T) { var buf bytes.Buffer s := &session{} s.srv = &Server{} s.br = bufio.NewReader(&buf) // Ensure readLine() returns an EOF error on an empty buffer. _, err := s.readLine() if err != io.EOF { t.Errorf("readLine() on empty buffer returned err: %v, want EOF", err) } // Ensure trailing is stripped. line := "FOO BAR BAZ\r\n" cmd := "FOO BAR BAZ" buf.Write([]byte(line)) output, err := s.readLine() if err != nil { t.Errorf("readLine(%v) returned err: %v", line, err) } else if output != cmd { t.Errorf("readLine(%v) returned %v, want %v", line, output, cmd) } } // Test reading of message data, including dot stuffing (see RFC 5321 section 4.5.2). func TestReadData(t *testing.T) { tests := []struct { lines string data string }{ // Single line message. {"Test message.\r\n.\r\n", "Test message.\r\n"}, // Single line message with leading period removed. {".Test message.\r\n.\r\n", "Test message.\r\n"}, // Multiple line message. {"Line 1.\r\nLine 2.\r\nLine 3.\r\n.\r\n", "Line 1.\r\nLine 2.\r\nLine 3.\r\n"}, // Multiple line message with leading period removed. {"Line 1.\r\n.Line 2.\r\nLine 3.\r\n.\r\n", "Line 1.\r\nLine 2.\r\nLine 3.\r\n"}, // Multiple line message with one leading period removed. {"Line 1.\r\n..Line 2.\r\nLine 3.\r\n.\r\n", "Line 1.\r\n.Line 2.\r\nLine 3.\r\n"}, } var buf bytes.Buffer s := &session{} s.srv = &Server{} s.br = bufio.NewReader(&buf) // Ensure readData() returns an EOF error on an empty buffer. _, err := s.readData() if err != io.EOF { t.Errorf("readData() on empty buffer returned err: %v, want EOF", err) } for _, tt := range tests { buf.Write([]byte(tt.lines)) data, err := s.readData() if err != nil { t.Errorf("readData(%v) returned err: %v", tt.lines, err) } else if string(data) != tt.data { t.Errorf("readData(%v) returned %v, want %v", tt.lines, string(data), tt.data) } } } // Test reading of message data with maximum size set (see RFC 1870 section 6.3). func TestReadDataWithMaxSize(t *testing.T) { tests := []struct { lines string maxSize int err error }{ // Maximum size of zero (the default) should not return an error. {"Test message.\r\n.\r\n", 0, nil}, // Messages below the maximum size should not return an error. {"Test message.\r\n.\r\n", 16, nil}, // Messages matching the maximum size should not return an error. {"Test message.\r\n.\r\n", 15, nil}, // Messages above the maximum size should return a maximum size exceeded error. {"Test message.\r\n.\r\n", 14, maxSizeExceeded(14)}, } var buf bytes.Buffer s := &session{} s.br = bufio.NewReader(&buf) for _, tt := range tests { s.srv = &Server{MaxSize: tt.maxSize} buf.Write([]byte(tt.lines)) _, err := s.readData() if err != tt.err { t.Errorf("readData(%v) returned err: %v", tt.lines, tt.err) } } } // Utility function for parsing extensions listed as service extensions in response to an EHLO command. func parseExtensions(t *testing.T, greeting string) map[string]string { extensions := make(map[string]string) lines := strings.Split(greeting, "\n") if len(lines) > 1 { iLast := len(lines) - 1 for i, line := range lines { prefix := line[0:4] // All but the last extension code prefix should be "250-". if i != iLast && prefix != "250-" { t.Errorf("Extension code prefix is %s, want '250-'", prefix) } // The last extension code prefix should be "250 ". if i == iLast && prefix != "250 " { t.Errorf("Extension code prefix is %s, want '250 '", prefix) } // Skip greeting line. if i == 0 { continue } // Add line as extension. line = strings.TrimSpace(line[4:]) // Strip code prefix and trailing \r\n if before, after, ok := strings.Cut(line, " "); ok { extensions[before] = after } else { extensions[line] = "" } } } return extensions } // Test handler function for validating authentication credentials. // The secret parameter is passed as nil for LOGIN and PLAIN authentication mechanisms. func testAuthHandler(_ net.Addr, _ string, username []byte, _ []byte, _ []byte) (bool, error) { return string(username) == "valid", nil } // Test the extensions listed in response to an EHLO command. func TestMakeEHLOResponse(t *testing.T) { s := &session{} s.srv = &Server{} // Greeting should be returned without trailing newlines. greeting := s.makeEHLOResponse() if len(greeting) != len(strings.TrimSpace(greeting)) { t.Errorf("EHLO greeting string has leading or trailing whitespace") } // By default, TLS is not configured, so STARTTLS should not appear. extensions := parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["STARTTLS"]; ok { t.Errorf("STARTTLS appears in the extension list when TLS is not configured") } // If TLS is configured, but not already in use, STARTTLS should appear. s.srv.TLSConfig = &tls.Config{} extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["STARTTLS"]; !ok { t.Errorf("STARTTLS does not appear in the extension list when TLS is configured") } // If TLS is already used on the connection, STARTTLS should not appear. s.tls = true extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["STARTTLS"]; ok { t.Errorf("STARTTLS appears in the extension list when TLS is already in use") } // Verify default SIZE extension is zero. s.srv = &Server{} extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["SIZE"]; !ok { t.Errorf("SIZE does not appear in the extension list") } else if extensions["SIZE"] != "0" { t.Errorf("SIZE appears in the extension list with incorrect parameter %s, want %s", extensions["SIZE"], "0") } // Verify configured maximum message size is listed correctly. // Any integer will suffice, as long as it's not hardcoded. maxSize := 10 + time.Now().Minute() maxSizeStr := fmt.Sprintf("%d", maxSize) s.srv = &Server{MaxSize: maxSize} extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["SIZE"]; !ok { t.Errorf("SIZE does not appear in the extension list") } else if extensions["SIZE"] != maxSizeStr { t.Errorf("SIZE appears in the extension list with incorrect parameter %s, want %s", extensions["SIZE"], maxSizeStr) } // With no authentication handler configured, AUTH should not be advertised. s.srv = &Server{} extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["AUTH"]; ok { t.Errorf("AUTH appears in the extension list when no AuthHandler is specified") } // With an authentication handler configured, AUTH should be advertised. s.srv = &Server{AuthHandler: testAuthHandler} extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["AUTH"]; !ok { t.Errorf("AUTH does not appear in the extension list when an AuthHandler is specified") } reLogin := regexp.MustCompile(`\bLOGIN\b`) rePlain := regexp.MustCompile(`\bPLAIN\b`) // RFC 4954 specifies that, without TLS in use, plaintext authentication mechanisms must not be advertised. s.tls = false extensions = parseExtensions(t, s.makeEHLOResponse()) if reLogin.MatchString(extensions["AUTH"]) { t.Errorf("AUTH mechanism LOGIN appears in the extension list when an AuthHandler is specified and TLS is not in use") } if rePlain.MatchString(extensions["AUTH"]) { t.Errorf("AUTH mechanism PLAIN appears in the extension list when an AuthHandler is specified and TLS is not in use") } // RFC 4954 specifies that, with TLS in use, plaintext authentication mechanisms can be advertised. s.tls = true extensions = parseExtensions(t, s.makeEHLOResponse()) if !reLogin.MatchString(extensions["AUTH"]) { t.Errorf("AUTH mechanism LOGIN does not appear in the extension list when an AuthHandler is specified and TLS is in use") } if !rePlain.MatchString(extensions["AUTH"]) { t.Errorf("AUTH mechanism PLAIN does not appear in the extension list when an AuthHandler is specified and TLS is in use") } // 8BITMIME should always be advertised s.srv = &Server{} s.tls = false extensions = parseExtensions(t, s.makeEHLOResponse()) if _, ok := extensions["8BITMIME"]; !ok { t.Errorf("8BITMIME does not appear in the extension list") } // SMTPUTF8 should always be advertised if _, ok := extensions["SMTPUTF8"]; !ok { t.Errorf("SMTPUTF8 does not appear in the extension list") } // ENHANCEDSTATUSCODES should always be advertised if _, ok := extensions["ENHANCEDSTATUSCODES"]; !ok { t.Errorf("ENHANCEDSTATUSCODES does not appear in the extension list") } } // Test 8BITMIME BODY parameter parsing in MAIL FROM command func TestCmd8BITMIME(t *testing.T) { srv := &Server{} conn := newConn(t, srv) cmdCode(t, conn, "EHLO host.example.com", "250") // Create a session to check internal state clientConn, serverConn := net.Pipe() session := srv.newSession(serverConn) go session.serve() // Read and discard banner _, _ = bufio.NewReader(clientConn).ReadString('\n') // Send EHLO _, _ = fmt.Fprintf(clientConn, "EHLO test.example.com\r\n") reader := bufio.NewReader(clientConn) for { line, _ := reader.ReadString('\n') if strings.HasPrefix(line, "250 ") { break } } // Test BODY=8BITMIME parameter _, _ = fmt.Fprintf(clientConn, "MAIL FROM: BODY=8BITMIME\r\n") resp, _ := reader.ReadString('\n') if !strings.HasPrefix(resp, "250") { t.Errorf("MAIL FROM with BODY=8BITMIME failed: %s", resp) } // Verify bodyEncoding was set (we can't directly access it, but we can test the behavior) // Reset and test BODY=7BIT _, _ = fmt.Fprintf(clientConn, "RSET\r\n") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "MAIL FROM: BODY=7BIT\r\n") resp, _ = reader.ReadString('\n') if !strings.HasPrefix(resp, "250") { t.Errorf("MAIL FROM with BODY=7BIT failed: %s", resp) } // Test BODY parameter with SIZE parameter _, _ = fmt.Fprintf(clientConn, "RSET\r\n") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "MAIL FROM: SIZE=1000 BODY=8BITMIME\r\n") resp, _ = reader.ReadString('\n') if !strings.HasPrefix(resp, "250") { t.Errorf("MAIL FROM with SIZE and BODY parameters failed: %s", resp) } // Test case insensitivity _, _ = fmt.Fprintf(clientConn, "RSET\r\n") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "MAIL FROM: body=8bitmime\r\n") resp, _ = reader.ReadString('\n') if !strings.HasPrefix(resp, "250") { t.Errorf("MAIL FROM with lowercase body parameter failed: %s", resp) } // Clean up _, _ = fmt.Fprintf(clientConn, "QUIT\r\n") _, _ = reader.ReadString('\n') _ = clientConn.Close() // Also test via the original connection cmdCode(t, conn, "MAIL FROM: BODY=8BITMIME", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "MAIL FROM: BODY=7BIT", "250") cmdCode(t, conn, "RSET", "250") cmdCode(t, conn, "MAIL FROM: BODY=8BITMIME SIZE=5000", "250") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } // func createTmpFile(content string) (file *os.File, err error) { // file, err = os.CreateTemp("", "") // if err != nil { // return // } // _, err = file.Write([]byte(content)) // if err != nil { // return // } // err = file.Close() // return // } // func createTLSFiles() ( // certFile *os.File, // keyFile *os.File, // passphrase string, // err error, // ) { // const certPEM = `-----BEGIN CERTIFICATE----- // MIIDRzCCAi+gAwIBAgIJAKtg4oViVwv4MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV // BAMMCWxvY2FsaG9zdDAgFw0xODA0MjAxMzMxNTBaGA8yMDg2MDUwODEzMzE1MFow // FDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB // CgKCAQEA8h7vl0gUquis5jRtcnETyD+8WITZO0s53aIzp0Y+9HXiHW6FGJjbOZjM // IvozNVni+83QWKumRTgeSzIIW2j4V8iFMSNrvWmhmCKloesXS1aY6H979e01Ve8J // WAJFRe6vZJd6gC6Z/P+ELU3ie4Vtr1GYfkV7nZ6VFp5/V/5nxGFag5TUlpP5hcoS // 9r2kvXofosVwe3x3udT8SEbv5eBD4bKeVyJs/RLbxSuiU1358Y1cDdVuHjcvfm3c // ajhheQ4vX9WXsk7LGGhnf1SrrPN/y+IDTXfvoHn+nJh4vMAB4yzQdE1V1N1AB8RA // 0yBVJ6dwxRrSg4BFrNWhj3gfsvrA7wIDAQABo4GZMIGWMB0GA1UdDgQWBBQ4/ncp // befFuKH1hoYkPqLwuRrPRjAfBgNVHSMEGDAWgBQ4/ncpbefFuKH1hoYkPqLwuRrP // RjAJBgNVHRMEAjAAMBEGCWCGSAGG+EIBAQQEAwIGQDALBgNVHQ8EBAMCBaAwEwYD // VR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3 // DQEBCwUAA4IBAQBJBetEXiEIzKAEpXGX87j6aUON51Fdf6BiLMCghuGKyhnaOG32 // 4KJhtvVoS3ZUKPylh9c2VdItYlhWp76zd7YKk+3xUOixWeTMQHIvCvRGTyFibOPT // mApwp2pEnJCe4vjUrBaRhiyI+xnB70cWVF2qeernlLUeJA1mfYyQLz+v06ebDWOL // c/hPVQFB94lEdiyjGO7RZfIe8KwcK48g7iv0LQU4+c9MoWM2ZsVM1AL2tHzokSeA // u64gDTW4K0Tzx1ab7KmOFXYUjbz/xWuReMt33EwDXAErKCjbVt2T55Qx8UoKzSh1 // tY0KDHdnYOzgsm2HIj2xcJqbeylYQvckNnoC // -----END CERTIFICATE-----` // const keyPEM = `-----BEGIN RSA PRIVATE KEY----- // Proc-Type: 4,ENCRYPTED // DEK-Info: AES-256-CBC,C16BF8745B2CDB53AC2B1D7609893AA0 // O13z7Yq7butaJmMfg9wRis9YnIDPsp4coYI6Ud+JGcP7iXoy95QMhovKWx25o1ol // tvUTsrsG27fHGf9qG02KizApIVtO9c1e0swCWzFrKRQX0JDiZDmilb9xosBNNst1 // BOzOTRZEwFGSOCKZRBfSXyqC93TvLJ3DO9IUnKIeGt7upipvg29b/Dur/fyCy2WV // bLHXwUTDBm7j49yfoEyGkDjoB2QO9wgcgbacbnQJQ25fTFUwZpZJEJv6o1tRhoYM // ZMOhC9x1URmdHKN1+z2y5BrB6oNpParfeAMEvs/9FE6jJwYUR28Ql6Mhphfvr9W2 // 5Gxd3J65Ao9Vi2I5j5X6aBuNjyhXN3ScLjPG4lVZm9RU/uTPEt81pig/d5nSAjvF // Nfc08NuG3cnMyJSE/xScJ4D+GtX8U969wO4oKPCR4E/NFyXPR730ppupDFG6hzPD // PDmiszDtU438JAZ8AuFa1LkbyFnEW6KVD4h7VRr8YDjirCqnkgjNSI6dFY0NQ8H7 // SyexB0lrceX6HZc+oNdAtkX3tYdzY3ExzUM5lSF1dkldnRbApLbqc4uuNIVXhXFM // dJnoPdKAzM6i+2EeVUxWNdafKDxnjVSHIHzHfIFJLQ4GS5rnz9keRFdyDjQL07tT // Lu9pPOmsadDXp7oSa81RgoCUfNZeR4jKpCk2BOft0L6ZSqwYFLcQHLIfJaGfn902 // TUOTxHt0KzEUYeYSrXC2a6cyvXAd1YI7lOgy60qG89VHyCc2v5Bs4c4FNUDC/+Dj // 4ZwogaAbSNkLaE0q3sYQRPdxSqLftyX0KitAgE7oGtdzBfe1cdBoozw3U67NEMMT // 6qvk5j7RepPRSrapHtK5pMMdg5XpKFWcOXZ26VHVrDCj4JKdjVb4iyiQi94VveV0 // w9+KcOtyrM7/jbQlCWnXpsIkP8VA/RIgh7CBn/h4oF1sO8ywP25OGQ7VWAVq1R9D // 8bl8GzIdR9PZpFyOxuIac4rPa8tkDeoXKs4cxoao7H/OZO9o9aTB7CJMTL9yv0Kb // ntWuYxQchE6syoGsOgdGyZhaw4JeFkasDUP5beyNY+278NkzgGTOIMMTXIX46woP // ehzHKGHXVGf7ZiSFF+zAHMXZRSwNVMkOYwlIoRg1IbvIRbAXqAR6xXQTCVzNG0SU // cskojycBca1Cz3hDVIKYZd9beDhprVdr2a4K2nft2g2xRNjKPopsaqXx+VPibFUx // X7542eQ3eAlhkWUuXvt0q5a9WJdjJp9ODA0/d0akF6JQlEHIAyLfoUKB1HYwgUGG // 6uRm651FDAab9U4cVC5PY1hfv/QwzpkNDkzgJAZ5SMOfZhq7IdBcqGd3lzPmq2FP // Vy1LVZIl3eM+9uJx5TLsBHH6NhMwtNhFCNa/5ksodQYlTvR8IrrgWlYg4EL69vjS // yt6HhhEN3lFCWvrQXQMp93UklbTlpVt6qcDXiC7HYbs3+EINargRd5Z+xL5i5vkN // f9k7s0xqhloWNPZcyOXMrox8L81WOY+sP4mVlGcfDRLdEJ8X2ofJpOAcwYCnjsKd // uEGsi+l2fTj/F+eZLE6sYoMprgJrbfeqtRWFguUgTn7s5hfU0tZ46al5d0vz8fWK // -----END RSA PRIVATE KEY-----` // passphrase = "test" // certFile, err = createTmpFile(certPEM) // if err != nil { // return // } // keyFile, err = createTmpFile(keyPEM) // return // } func TestAuthMechs(t *testing.T) { s := session{} s.srv = &Server{} // Validate that non-TLS (default) configuration does not allow plaintext authentication mechanisms. correct := map[string]bool{"LOGIN": false, "PLAIN": false, "CRAM-MD5": true} mechs := s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } // Validate that TLS configuration allows plaintext authentication mechanisms. correct = map[string]bool{"LOGIN": true, "PLAIN": true, "CRAM-MD5": true} s.tls = true mechs = s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } // Validate that overridden values take precedence over RFC compliance when not using TLS. correct = map[string]bool{"LOGIN": true, "PLAIN": true, "CRAM-MD5": false} s.tls = false s.srv.AuthMechs = map[string]bool{"LOGIN": true, "PLAIN": true, "CRAM-MD5": false} mechs = s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } // Validate that overridden values take precedence over RFC compliance when using TLS. correct = map[string]bool{"LOGIN": false, "PLAIN": false, "CRAM-MD5": true} s.tls = true s.srv.AuthMechs = map[string]bool{"LOGIN": false, "PLAIN": false, "CRAM-MD5": true} mechs = s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } // Validate ability to explicitly disallow all mechanisms. correct = map[string]bool{"LOGIN": false, "PLAIN": false, "CRAM-MD5": false} s.srv.AuthMechs = map[string]bool{"LOGIN": false, "PLAIN": false, "CRAM-MD5": false} mechs = s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } // Validate ability to explicitly allow all mechanisms. correct = map[string]bool{"LOGIN": true, "PLAIN": true, "CRAM-MD5": true} s.srv.AuthMechs = map[string]bool{"LOGIN": true, "PLAIN": true, "CRAM-MD5": true} mechs = s.authMechs() if !reflect.DeepEqual(mechs, correct) { t.Errorf("authMechs() returned %v, want %v", mechs, correct) } } func TestCmdAUTH(t *testing.T) { server := &Server{} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // By default no authentication handler is configured, so AUTH should return 502 not implemented. cmdCode(t, conn, "AUTH", "502") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdAUTHOptional(t *testing.T) { server := &Server{AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH without mechanism parameter must return 501 syntax error. cmdCode(t, conn, "AUTH", "501") // AUTH with a supported mechanism should return 334. cmdCode(t, conn, "AUTH CRAM-MD5", "334") // AUTH must support cancellation with '*' and return 501 syntax error. cmdCode(t, conn, "*", "501") // AUTH with an unsupported mechanism should return 504 unrecognized type. cmdCode(t, conn, "AUTH FOO", "504") // The LOGIN and PLAIN mechanisms require a TLS connection, and are disabled by default. cmdCode(t, conn, "AUTH LOGIN", "504") cmdCode(t, conn, "AUTH PLAIN", "504") // AUTH attempt during a mail transaction must return 503 bad sequence. cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "AUTH CRAM-MD5", "503") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "AUTH CRAM-MD5", "503") // AUTH after a mail transaction must return 334. // TODO: Work out what should happen if AUTH is received after DATA. cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Test message\r\n.", "250") cmdCode(t, conn, "AUTH CRAM-MD5", "334") // Cancel the authentication attempt, otherwise the QUIT below will return 502. // TODO: Work out what should happen if QUIT is received after AUTH. cmdCode(t, conn, "*", "501") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdAUTHRequired(t *testing.T) { server := &Server{AuthHandler: testAuthHandler, AuthRequired: true} conn := newConn(t, server) tests := []struct { cmd string codeBefore string codeAfter string }{ {"EHLO host.example.com", "250", "250"}, {"NOOP", "250", "250"}, {"MAIL FROM:", "530", "250"}, {"RCPT TO:", "530", "250"}, {"RSET", "250", "250"}, // Reset before DATA to avoid having to actually send a message. {"DATA", "530", "503"}, {"HELP", "502", "502"}, {"VRFY", "502", "502"}, {"EXPN", "502", "502"}, {"TEST", "500", "500"}, // Unsupported command {"", "500", "500"}, // Blank command {"STARTTLS", "502", "502"}, // TLS not configured } // If authentication is configured and required, but not already in use, reject every command except // AUTH, EHLO, HELO, NOOP, RSET, or QUIT as per RFC 4954. for _, tt := range tests { cmdCode(t, conn, tt.cmd, tt.codeBefore) } // AUTH without mechanism parameter must return 501 syntax error. cmdCode(t, conn, "AUTH", "501") // AUTH with a supported mechanism should return 334. cmdCode(t, conn, "AUTH CRAM-MD5", "334") // AUTH must support cancellation with '*' and return 501 syntax error. cmdCode(t, conn, "*", "501") // AUTH with an unsupported mechanism should return 504 unrecognized type. cmdCode(t, conn, "AUTH FOO", "504") // The LOGIN and PLAIN mechanisms require a TLS connection, and are disabled by default. cmdCode(t, conn, "AUTH LOGIN", "504") cmdCode(t, conn, "AUTH PLAIN", "504") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdAUTHLOGIN(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH LOGIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH LOGIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH LOGIN with TLS in use can proceed. // LOGIN authentication process: // Client sends "AUTH LOGIN" // Server sends "334 VXNlcm5hbWU6" (Base64-encoded "Username:"). // Client sends Base64-encoded username. // Server sends "334 UGFzc3dvcmQ6" (Base64-encoded "Password:"). // Client sends Base64-encoded password. invalidBase64 := "==" // Invalid Base64 string. validUsername := base64.StdEncoding.EncodeToString([]byte("valid")) invalidUsername := base64.StdEncoding.EncodeToString([]byte("invalid")) password := base64.StdEncoding.EncodeToString([]byte("password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH LOGIN", "334") cmdCode(t, tlsConn, invalidBase64, "501") cmdCode(t, tlsConn, "AUTH LOGIN", "334") cmdCode(t, tlsConn, validUsername, "334") cmdCode(t, tlsConn, invalidBase64, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH LOGIN", "334") cmdCode(t, tlsConn, invalidUsername, "334") cmdCode(t, tlsConn, password, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH LOGIN", "334") cmdCode(t, tlsConn, validUsername, "334") cmdCode(t, tlsConn, password, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdAUTHLOGINFast(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH LOGIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH LOGIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH LOGIN with TLS in use can proceed. // Fast LOGIN authentication process: // Client sends "AUTH LOGIN " plus Base64-encoded username. // Server sends "334 UGFzc3dvcmQ6" (Base64-encoded "Password:"). // Client sends Base64-encoded password. invalidBase64 := "==" // Invalid Base64 string. validUsername := base64.StdEncoding.EncodeToString([]byte("valid")) invalidUsername := base64.StdEncoding.EncodeToString([]byte("invalid")) password := base64.StdEncoding.EncodeToString([]byte("password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH LOGIN "+invalidBase64, "501") cmdCode(t, tlsConn, "AUTH LOGIN "+validUsername, "334") cmdCode(t, tlsConn, invalidBase64, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH LOGIN "+invalidUsername, "334") cmdCode(t, tlsConn, password, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH LOGIN", "334") cmdCode(t, tlsConn, validUsername, "334") cmdCode(t, tlsConn, password, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdAUTHPLAIN(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH PLAIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH PLAIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH PLAIN with TLS in use can proceed. // RFC 2595 specifies: // The client sends the authorization identity (identity to // login as), followed by a US-ASCII NUL character, followed by the // authentication identity (identity whose password will be used), // followed by a US-ASCII NUL character, followed by the clear-text // password. The client may leave the authorization identity empty to // indicate that it is the same as the authentication identity. // PLAIN authentication process: // Client sends "AUTH PLAIN" // Server sends "334 " (RFC 4954 requires the space). // Client sends Base64-encoded string: identity\0username\0password invalidBase64 := "==" // Invalid Base64 string. missingNUL := base64.StdEncoding.EncodeToString([]byte("valid\x00password")) valid := base64.StdEncoding.EncodeToString([]byte("identity\x00valid\x00password")) invalid := base64.StdEncoding.EncodeToString([]byte("identity\x00invalid\x00password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, invalidBase64, "501") cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, missingNUL, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, invalid, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdAUTHPLAINEmpty(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH PLAIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH PLAIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH PLAIN with TLS in use can proceed. // RFC 2595 specifies: // The client sends the authorization identity (identity to // login as), followed by a US-ASCII NUL character, followed by the // authentication identity (identity whose password will be used), // followed by a US-ASCII NUL character, followed by the clear-text // password. The client may leave the authorization identity empty to // indicate that it is the same as the authentication identity. // PLAIN authentication process with empty authorisation identity: // Client sends "AUTH PLAIN" // Server sends "334 " (RFC 4954 requires the space). // Client sends Base64-encoded string: \0username\0password invalidBase64 := "==" // Invalid Base64 string. missingNUL := base64.StdEncoding.EncodeToString([]byte("valid\x00password")) valid := base64.StdEncoding.EncodeToString([]byte("\x00valid\x00password")) invalid := base64.StdEncoding.EncodeToString([]byte("\x00invalid\x00password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, invalidBase64, "501") cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, missingNUL, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, invalid, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH PLAIN", "334") cmdCode(t, tlsConn, valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdAUTHPLAINFast(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH PLAIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH PLAIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH PLAIN with TLS in use can proceed. // RFC 2595 specifies: // The client sends the authorization identity (identity to // login as), followed by a US-ASCII NUL character, followed by the // authentication identity (identity whose password will be used), // followed by a US-ASCII NUL character, followed by the clear-text // password. The client may leave the authorization identity empty to // indicate that it is the same as the authentication identity. // Fast PLAIN authentication process: // Client sends "AUTH PLAIN " plus Base64-encoded string: identity\0username\0password invalidBase64 := "==" // Invalid Base64 string. missingNUL := base64.StdEncoding.EncodeToString([]byte("valid\x00password")) valid := base64.StdEncoding.EncodeToString([]byte("identity\x00valid\x00password")) invalid := base64.StdEncoding.EncodeToString([]byte("identity\x00invalid\x00password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH PLAIN "+invalidBase64, "501") cmdCode(t, tlsConn, "AUTH PLAIN "+missingNUL, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH PLAIN "+invalid, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH PLAIN "+valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } func TestCmdAUTHPLAINFastAndEmpty(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH PLAIN without TLS in use must return 504 unrecognised type. cmdCode(t, conn, "AUTH PLAIN", "504") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH PLAIN with TLS in use can proceed. // RFC 2595 specifies: // The client sends the authorization identity (identity to // login as), followed by a US-ASCII NUL character, followed by the // authentication identity (identity whose password will be used), // followed by a US-ASCII NUL character, followed by the clear-text // password. The client may leave the authorization identity empty to // indicate that it is the same as the authentication identity. // Fast PLAIN authentication process with empty authorisation identity: // Client sends "AUTH PLAIN " plus Base64-encoded string: \0username\0password invalidBase64 := "==" // Invalid Base64 string. missingNUL := base64.StdEncoding.EncodeToString([]byte("valid\x00password")) valid := base64.StdEncoding.EncodeToString([]byte("\x00valid\x00password")) invalid := base64.StdEncoding.EncodeToString([]byte("\x00invalid\x00password")) // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH PLAIN "+invalidBase64, "501") cmdCode(t, tlsConn, "AUTH PLAIN "+missingNUL, "501") // Invalid credentials must return 535 authentication credentials invalid. cmdCode(t, tlsConn, "AUTH PLAIN "+invalid, "535") // Valid credentials must return 235 authentication succeeded. cmdCode(t, tlsConn, "AUTH PLAIN "+valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } // makeCRAMMD5Response is a helper function to create the CRAM-MD5 hash. func makeCRAMMD5Response(challenge string, username string, secret string) (string, error) { decoded, err := base64.StdEncoding.DecodeString(challenge) if err != nil { return "", err } hash := hmac.New(md5.New, []byte(secret)) hash.Write(decoded) buffer := make([]byte, 0, hash.Size()) response := fmt.Sprintf("%s %x", username, hash.Sum(buffer)) return base64.StdEncoding.EncodeToString([]byte(response)), nil } func TestCmdAUTHCRAMMD5(t *testing.T) { server := &Server{AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // AUTH CRAM-MD5 without TLS in use can proceed. // RFC 2195 specifies: // The challenge format is that of a Message-ID email header value. // Challenge format: '<' + random digits + '.' + timestamp in digits + '@' + fully-qualified server hostname + '>' // Challenge example: <1896.697170952@postoffice.reston.mci.net> // The response format consists of the username, a space and a digest. // Digest calculation: MD5((secret XOR opad), MD5((secret XOR ipad), challenge)) // Response example: tim b913a602c7eda7a495b4e6e7334d3890 // CRAM-MD5 authentication process: // Client sends "AUTH CRAM-MD5". // Server sends "334 " plus Base64-encoded challenge. // Client sends Base64-encoded response. invalidBase64 := "==" // Invalid Base64 string. // Corrupt credentials must return 501 syntax error. cmdCode(t, conn, "AUTH CRAM-MD5", "334") cmdCode(t, conn, invalidBase64, "501") // Test valid credentials with missing space (causing a parse error). line := cmdCode(t, conn, "AUTH CRAM-MD5", "334") valid, _ := makeCRAMMD5Response(line[4:], "valid", "password") buffer, _ := base64.StdEncoding.DecodeString(valid) buffer = bytes.Replace(buffer, []byte(" "), []byte(""), 1) missingSpace := base64.StdEncoding.EncodeToString(buffer) cmdCode(t, conn, string(missingSpace), "501") // Invalid credentials must return 535 authentication credentials invalid. line = cmdCode(t, conn, "AUTH CRAM-MD5", "334") invalid, err := makeCRAMMD5Response(line[4:], "invalid", "password") if err != nil { cmdCode(t, conn, "*", "501") } cmdCode(t, conn, invalid, "535") // Valid credentials must return 235 authentication succeeded. line = cmdCode(t, conn, "AUTH CRAM-MD5", "334") valid, err = makeCRAMMD5Response(line[4:], "valid", "password") if err != nil { cmdCode(t, conn, "*", "501") } cmdCode(t, conn, valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, conn, "AUTH LOGIN", "503") cmdCode(t, conn, "AUTH PLAIN", "503") cmdCode(t, conn, "AUTH CRAM-MD5", "503") cmdCode(t, conn, "QUIT", "221") _ = conn.Close() } func TestCmdAUTHCRAMMD5WithTLS(t *testing.T) { server := &Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}, AuthHandler: testAuthHandler} conn := newConn(t, server) cmdCode(t, conn, "EHLO host.example.com", "250") // Upgrade to TLS. cmdCode(t, conn, "STARTTLS", "220") tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) err := tlsConn.Handshake() if err != nil { t.Errorf("Failed to perform TLS handshake") } cmdCode(t, tlsConn, "EHLO host.example.com", "250") // AUTH CRAM-MD5 with TLS in use can proceed. // RFC 2195 specifies: // The challenge format is that of a Message-ID email header value. // Challenge format: '<' + random digits + '.' + timestamp in digits + '@' + fully-qualified server hostname + '>' // Challenge example: <1896.697170952@postoffice.reston.mci.net> // The response format consists of the username, a space and a digest. // Digest calculation: MD5((secret XOR opad), MD5((secret XOR ipad), challenge)) // Response example: tim b913a602c7eda7a495b4e6e7334d3890 // CRAM-MD5 authentication process: // Client sends "AUTH CRAM-MD5". // Server sends "334 " plus Base64-encoded challenge. // Client sends Base64-encoded response. invalidBase64 := "==" // Invalid Base64 string. // Corrupt credentials must return 501 syntax error. cmdCode(t, tlsConn, "AUTH CRAM-MD5", "334") cmdCode(t, tlsConn, invalidBase64, "501") // Test valid credentials with missing space (causing a parse error). line := cmdCode(t, tlsConn, "AUTH CRAM-MD5", "334") valid, _ := makeCRAMMD5Response(line[4:], "valid", "password") buffer, _ := base64.StdEncoding.DecodeString(valid) buffer = bytes.Replace(buffer, []byte(" "), []byte(""), 1) missingSpace := base64.StdEncoding.EncodeToString(buffer) cmdCode(t, tlsConn, string(missingSpace), "501") // Invalid credentials must return 535 authentication credentials invalid. line = cmdCode(t, tlsConn, "AUTH CRAM-MD5", "334") invalid, err := makeCRAMMD5Response(line[4:], "invalid", "password") if err != nil { cmdCode(t, tlsConn, "*", "501") } cmdCode(t, tlsConn, invalid, "535") // Valid credentials must return 235 authentication succeeded. line = cmdCode(t, tlsConn, "AUTH CRAM-MD5", "334") valid, err = makeCRAMMD5Response(line[4:], "valid", "password") if err != nil { cmdCode(t, tlsConn, "*", "501") } cmdCode(t, tlsConn, valid, "235") // AUTH after prior successful AUTH must return 503 bad sequence. cmdCode(t, tlsConn, "AUTH LOGIN", "503") cmdCode(t, tlsConn, "AUTH PLAIN", "503") cmdCode(t, tlsConn, "AUTH CRAM-MD5", "503") cmdCode(t, tlsConn, "QUIT", "221") _ = tlsConn.Close() } // Benchmark the mail handling without the network stack introducing latency. func BenchmarkReceive(b *testing.B) { server := &Server{} // Default server configuration. clientConn, serverConn := net.Pipe() session := server.newSession(serverConn) go session.serve() reader := bufio.NewReader(clientConn) _, _ = reader.ReadString('\n') // Read greeting message first. b.ResetTimer() // Benchmark a full mail transaction. for i := 0; i < b.N; i++ { _, _ = fmt.Fprintf(clientConn, "%s\r\n", "HELO host.example.com") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "%s\r\n", "MAIL FROM:") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "%s\r\n", "RCPT TO:") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "%s\r\n", "DATA") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "%s\r\n", "Test message.\r\n.") _, _ = reader.ReadString('\n') _, _ = fmt.Fprintf(clientConn, "%s\r\n", "QUIT") _, _ = reader.ReadString('\n') } } func TestCmdShutdown(t *testing.T) { srv := &Server{} conn := newConn(t, srv) // Send HELO, expect greeting. cmdCode(t, conn, "HELO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") cmdCode(t, conn, "HELO host.example.com", "250") cmdCode(t, conn, "DATA", "503") go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { t.Errorf("Error shutting down server: %v\n", err) } }() // give the shutdown time to act time.Sleep(200 * time.Millisecond) // shutdown will wait until the end of the session cmdCode(t, conn, "HELO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") cmdCode(t, conn, "RCPT TO:", "250") // this will trigger the close cmdCode(t, conn, "QUIT", "221") // connection should now be closed _, _ = fmt.Fprintf(conn, "%s\r\n", "HELO host.example.com") _, err := bufio.NewReader(conn).ReadString('\n') if err != io.EOF { t.Errorf("Expected connection to be closed\n") } _ = conn.Close() } type mockDropRejectedHandler struct { handlerCalled int lastFrom string lastTo []string msgIDCalled int lastMsgIDFrom string lastMsgIDTo []string } func (m *mockDropRejectedHandler) handler(remoteAddr net.Addr, from string, to []string, data []byte) error { m.handlerCalled++ m.lastFrom = from m.lastTo = append([]string{}, to...) // copy slice return nil } func (m *mockDropRejectedHandler) msgIDHandler(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) { m.msgIDCalled++ m.lastMsgIDFrom = from m.lastMsgIDTo = append([]string{}, to...) // copy slice return "test-message-id", nil } // Test the IgnoreRejectedRecipients option func TestIgnoreRejectedRecipients(t *testing.T) { tests := []struct { name string IgnoreRejectedRecipients bool handlerRcpt func(net.Addr, string, string) bool rcptCommands []struct{ addr, expectedCode string } expectedHandlerCalls int expectedHandlerRecipients []string useMsgIDHandler bool }{ { name: "Disabled_DefaultBehavior", IgnoreRejectedRecipients: false, handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool { return !strings.HasSuffix(to, "@rejected.com") }, rcptCommands: []struct{ addr, expectedCode string }{ {"valid@example.com", "250"}, {"invalid@rejected.com", "550"}, }, expectedHandlerCalls: 1, expectedHandlerRecipients: []string{"valid@example.com"}, }, { name: "Enabled_MixedRecipients", IgnoreRejectedRecipients: true, handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool { return !strings.HasSuffix(to, "@rejected.com") }, rcptCommands: []struct{ addr, expectedCode string }{ {"valid1@example.com", "250"}, {"valid2@example.com", "250"}, {"invalid1@rejected.com", "250"}, // Now accepted but dropped {"invalid2@rejected.com", "250"}, // Now accepted but dropped }, expectedHandlerCalls: 1, expectedHandlerRecipients: []string{"valid1@example.com", "valid2@example.com"}, }, { name: "Enabled_AllRejected", IgnoreRejectedRecipients: true, handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool { return false // Reject all }, rcptCommands: []struct{ addr, expectedCode string }{ {"test1@example.com", "250"}, // Accepted but dropped {"test2@example.com", "250"}, // Accepted but dropped }, expectedHandlerCalls: 0, // No handler calls since all rejected expectedHandlerRecipients: nil, }, { name: "Enabled_OnlyValid", IgnoreRejectedRecipients: true, handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool { return strings.HasSuffix(to, "@valid.com") }, rcptCommands: []struct{ addr, expectedCode string }{ {"user1@valid.com", "250"}, {"user2@valid.com", "250"}, {"user3@valid.com", "250"}, }, expectedHandlerCalls: 1, expectedHandlerRecipients: []string{"user1@valid.com", "user2@valid.com", "user3@valid.com"}, }, { name: "Enabled_WithMsgIDHandler", IgnoreRejectedRecipients: true, handlerRcpt: func(remoteAddr net.Addr, from string, to string) bool { return !strings.HasSuffix(to, "@rejected.com") }, rcptCommands: []struct{ addr, expectedCode string }{ {"valid@example.com", "250"}, {"invalid@rejected.com", "250"}, // Accepted but dropped }, expectedHandlerCalls: 1, expectedHandlerRecipients: []string{"valid@example.com"}, useMsgIDHandler: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mock := &mockDropRejectedHandler{} server := &Server{ Hostname: "mail.example.com", AppName: "TestMail", MaxRecipients: 100, HandlerRcpt: tt.handlerRcpt, IgnoreRejectedRecipients: tt.IgnoreRejectedRecipients, } if tt.useMsgIDHandler { server.MsgIDHandler = mock.msgIDHandler } else { server.Handler = mock.handler } conn := newConn(t, server) defer func() { _ = conn.Close() }() cmdCode(t, conn, "HELO host.example.com", "250") cmdCode(t, conn, "MAIL FROM:", "250") // Send RCPT commands for _, rcpt := range tt.rcptCommands { cmdCode(t, conn, "RCPT TO:<"+rcpt.addr+">", rcpt.expectedCode) } // Send DATA cmdCode(t, conn, "DATA", "354") cmdCode(t, conn, "Subject: Test\r\n\r\nTest message\r\n.", "250") cmdCode(t, conn, "QUIT", "221") // Verify handler calls if tt.useMsgIDHandler { if mock.msgIDCalled != tt.expectedHandlerCalls { t.Errorf("Expected %d MsgIDHandler calls, got %d", tt.expectedHandlerCalls, mock.msgIDCalled) } if tt.expectedHandlerCalls > 0 { if mock.lastMsgIDFrom != "sender@example.com" { t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastMsgIDFrom) } if !reflect.DeepEqual(mock.lastMsgIDTo, tt.expectedHandlerRecipients) { t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastMsgIDTo) } } } else { if mock.handlerCalled != tt.expectedHandlerCalls { t.Errorf("Expected %d handler calls, got %d", tt.expectedHandlerCalls, mock.handlerCalled) } if tt.expectedHandlerCalls > 0 { if mock.lastFrom != "sender@example.com" { t.Errorf("Expected from 'sender@example.com', got '%s'", mock.lastFrom) } if !reflect.DeepEqual(mock.lastTo, tt.expectedHandlerRecipients) { t.Errorf("Expected recipients %v, got %v", tt.expectedHandlerRecipients, mock.lastTo) } } } }) } } ================================================ FILE: internal/snakeoil/snakeoil.go ================================================ // Package snakeoil provides functionality to generate a temporary self-signed certificates // for testing purposes. It generates a public and private key pair, stores them in the // OS's temporary directory, returning the paths to these files. package snakeoil import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/pem" "errors" "math/big" "os" "strings" "time" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" ) var keys = make(map[string]KeyPair) // KeyPair holds the public and private key paths for a self-signed certificate. type KeyPair struct { Public string Private string } // Certificates returns all configured self-signed certificates in use, // used for file deletion on exit. func Certificates() map[string]KeyPair { return keys } // Public returns the path to a generated PEM-encoded RSA public key. func Public(str string) string { domains, key, err := parse(str) if err != nil { logger.Log().Errorf("[tls] failed to parse domains: %v", err) return "" } if pair, ok := keys[key]; ok { return pair.Public } private, public, err := generate(domains) if err != nil { logger.Log().Errorf("[tls] failed to generate public certificate: %v", err) return "" } keys[key] = KeyPair{ Public: public, Private: private, } return public } // Private returns the path to a generated PEM-encoded RSA private key. func Private(str string) string { domains, key, err := parse(str) if err != nil { logger.Log().Errorf("[tls] failed to parse domains: %v", err) return "" } if pair, ok := keys[key]; ok { return pair.Private } private, public, err := generate(domains) if err != nil { logger.Log().Errorf("[tls] failed to generate public certificate: %v", err) return "" } keys[key] = KeyPair{ Public: public, Private: private, } return private } // Parse takes the original string input, removes the "sans:" prefix, // splits the result into individual domains, and returns a slice of unique domains, // along with a unique key that is a comma-separated list of these domains. func parse(str string) ([]string, string, error) { // remove "sans:" prefix str = str[5:] var domains []string // split the string by commas and trim whitespace for domain := range strings.SplitSeq(str, ",") { domain = strings.ToLower(strings.TrimSpace(domain)) if domain != "" && !tools.InArray(domain, domains) { domains = append(domains, domain) } } if len(domains) == 0 { return domains, "", errors.New("no valid domains provided") } // generate sha256 hash of the domains to create a unique key hasher := sha256.New() hasher.Write([]byte(strings.Join(domains, ","))) key := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) return domains, key, nil } // Generate a new self-signed certificate and return a public & private key paths. func generate(domains []string) (string, string, error) { logger.Log().Infof("[tls] generating temp self-signed certificate for: %s", strings.Join(domains, ",")) key, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return "", "", err } keyBytes := x509.MarshalPKCS1PrivateKey(key) // PEM encoding of private key keyPEM := pem.EncodeToMemory( &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: keyBytes, }, ) notBefore := time.Now() notAfter := notBefore.Add(365 * 24 * time.Hour) // create certificate template template := x509.Certificate{ SerialNumber: big.NewInt(0), Subject: pkix.Name{ CommonName: domains[0], Organization: []string{"Mailpit self-signed certificate"}, }, DNSNames: domains, SignatureAlgorithm: x509.SHA256WithRSA, NotBefore: notBefore, NotAfter: notAfter, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, } // create certificate using template derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) if err != nil { return "", "", err } // PEM encoding of certificate certPem := pem.EncodeToMemory( &pem.Block{ Type: "CERTIFICATE", Bytes: derBytes, }, ) // Store the paths to the generated keys priv, err := os.CreateTemp("", ".mailpit-*-private.pem") if err != nil { return "", "", err } if _, err := priv.Write(keyPEM); err != nil { return "", "", err } if err := priv.Close(); err != nil { return "", "", err } pub, err := os.CreateTemp("", ".mailpit-*-public.pem") if err != nil { return "", "", err } if _, err := pub.Write(certPem); err != nil { return "", "", err } if err := pub.Close(); err != nil { return "", "", err } return priv.Name(), pub.Name(), nil } ================================================ FILE: internal/spamassassin/postmark/postmark.go ================================================ // Package postmark uses the free https://spamcheck.postmarkapp.com/ // See https://spamcheck.postmarkapp.com/doc/ for more details. package postmark import ( "bytes" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" ) // Response struct type Response struct { Success bool `json:"success"` Message string `json:"message"` // for errors only Score string `json:"score"` Rules []Rule `json:"rules"` Report string `json:"report"` // ignored } // Rule struct type Rule struct { Score string `json:"score"` // Name not returned by postmark but rather extracted from description Name string `json:"name"` Description string `json:"description"` } // Check will post the email data to Postmark func Check(email []byte, timeout int) (Response, error) { r := Response{} // '{"email":"raw dump of email", "options":"short"}' var d struct { // The raw dump of the email to be filtered, including all headers. Email string `json:"email"` // Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request. Options string `json:"options"` } d.Email = string(email) d.Options = "long" data, err := json.Marshal(d) if err != nil { return r, err } client := http.Client{ Timeout: time.Duration(timeout) * time.Second, } resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json", bytes.NewBuffer(data)) if err != nil { return r, err } defer func() { _ = resp.Body.Close() }() err = json.NewDecoder(resp.Body).Decode(&r) // remove trailing line spaces for all lines in report re := regexp.MustCompile("\r?\n") lines := re.Split(r.Report, -1) reportLines := []string{} for _, l := range lines { line := strings.TrimRight(l, " ") reportLines = append(reportLines, line) } reportRaw := strings.Join(reportLines, "\n") // join description lines to make a single line per rule re2 := regexp.MustCompile("\n ") report := re2.ReplaceAllString(reportRaw, "") for i, rule := range r.Rules { // populate rule name r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report) } return r, err } // Extract the name of the test from the report as Postmark does not include this in the JSON reports func nameFromReport(score, description, report string) string { score = regexp.QuoteMeta(score) description = regexp.QuoteMeta(description) str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description) re := regexp.MustCompile(str) matches := re.FindAllStringSubmatch(report, 1) if len(matches) > 0 && len(matches[0]) == 2 { return strings.TrimSpace(matches[0][1]) } return "" } ================================================ FILE: internal/spamassassin/spamassassin.go ================================================ // Package spamassassin will return results from either a SpamAssassin server or // Postmark's public API depending on configuration package spamassassin import ( "errors" "math" "strconv" "strings" "github.com/axllent/mailpit/internal/spamassassin/postmark" "github.com/axllent/mailpit/internal/spamassassin/spamc" ) var ( // Service to use, either ":" for self-hosted SpamAssassin or "postmark" service string // SpamScore is the score at which a message is determined to be spam spamScore = 5.0 // Timeout in seconds timeout = 8 ) // Result is a SpamAssassin result // // swagger:model SpamAssassinResponse type Result struct { // Whether the message is spam or not IsSpam bool // If populated will return an error string Error string // Total spam score based on triggered rules Score float64 // Spam rules triggered Rules []Rule } // Rule struct type Rule struct { // Spam rule score Score float64 // SpamAssassin rule name Name string // SpamAssassin rule description Description string } // SetService defines which service should be used. func SetService(s string) { switch s { case "postmark": service = "postmark" default: service = s } } // Ping returns whether a service is active or not func Ping() error { if service == "postmark" { return nil } var client *spamc.Client if strings.HasPrefix(service, "unix:") { client = spamc.NewUnix(strings.TrimLeft(service, "unix:")) } else { client = spamc.NewTCP(service, timeout) } return client.Ping() } // Check will return a Result func Check(msg []byte) (Result, error) { r := Result{Score: 0} if service == "" { return r, errors.New("no SpamAssassin service defined") } if service == "postmark" { res, err := postmark.Check(msg, timeout) if err != nil { r.Error = err.Error() return r, nil } resFloat, err := strconv.ParseFloat(res.Score, 32) if err == nil { r.Score = round1dm(resFloat) r.IsSpam = resFloat >= spamScore } r.Error = res.Message for _, pr := range res.Rules { rule := Rule{} value, err := strconv.ParseFloat(pr.Score, 32) if err == nil { rule.Score = round1dm(value) } rule.Name = pr.Name rule.Description = pr.Description r.Rules = append(r.Rules, rule) } } else { var client *spamc.Client if strings.HasPrefix(service, "unix:") { client = spamc.NewUnix(strings.TrimLeft(service, "unix:")) } else { client = spamc.NewTCP(service, timeout) } res, err := client.Report(msg) if err != nil { r.Error = err.Error() return r, nil } r.IsSpam = res.Score >= spamScore r.Score = round1dm(res.Score) r.Rules = []Rule{} for _, sr := range res.Rules { rule := Rule{} value, err := strconv.ParseFloat(sr.Points, 32) if err == nil { rule.Score = round1dm(value) } rule.Name = sr.Name rule.Description = sr.Description r.Rules = append(r.Rules, rule) } } return r, nil } // Round to one decimal place func round1dm(n float64) float64 { return math.Floor(n*10) / 10 } ================================================ FILE: internal/spamassassin/spamc/spamc.go ================================================ // Package spamc provides a client for the SpamAssassin spamd protocol. // http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL // // Modified to add timeouts from https://github.com/cgt/spamc package spamc import ( "bufio" "fmt" "io" "net" "regexp" "strconv" "strings" "time" "github.com/axllent/mailpit/internal/tools" ) // ProtoVersion is the protocol version const ProtoVersion = "1.5" var ( spamInfoRe = regexp.MustCompile(`(.+)\/(.+) (\d+) (.+)`) spamMainRe = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`) spamDetailsRe = regexp.MustCompile(`^\s?(-?[0-9\.]+)\s([a-zA-Z0-9_]*)(\W*)(.*)`) ) // connection is like net.Conn except that it also has a CloseWrite method. // CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some // reason it is not present in the net.Conn interface. type connection interface { net.Conn CloseWrite() error } // Client is a spamd client. type Client struct { net string addr string timeout int } // NewTCP returns a *Client that connects to spamd via the given TCP address. func NewTCP(addr string, timeout int) *Client { return &Client{"tcp", addr, timeout} } // NewUnix returns a *Client that connects to spamd via the given Unix socket. func NewUnix(addr string) *Client { return &Client{"unix", addr, 0} } // Rule represents a matched SpamAssassin rule. type Rule struct { Points string Name string Description string } // Result struct type Result struct { ResponseCode int Message string Spam bool Score float64 Threshold float64 Rules []Rule } // dial connects to spamd through TCP or a Unix socket. func (c *Client) dial() (connection, error) { switch c.net { case "tcp": tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr) if err != nil { return nil, err } return net.DialTCP("tcp", nil, tcpAddr) case "unix": unixAddr, err := net.ResolveUnixAddr("unix", c.addr) if err != nil { return nil, err } return net.DialUnix("unix", nil, unixAddr) default: return nil, fmt.Errorf("unsupported network type: %s", c.net) } } // Report checks if message is spam or not, and returns score plus report func (c *Client) Report(email []byte) (Result, error) { output, err := c.report(email) if err != nil { return Result{}, err } return c.parseOutput(output), nil } func (c *Client) report(email []byte) ([]string, error) { conn, err := c.dial() if err != nil { return nil, err } defer func() { _ = conn.Close() }() if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil { return nil, err } bw := bufio.NewWriter(conn) if _, err := bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n"); err != nil { return nil, err } if _, err := bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n"); err != nil { return nil, err } if _, err := bw.Write(email); err != nil { return nil, err } if err := bw.Flush(); err != nil { return nil, err } // Client is supposed to close its writing side of the connection // after sending its request. if err := conn.CloseWrite(); err != nil { return nil, err } var ( lines []string br = bufio.NewReader(conn) ) for { line, err := br.ReadString('\n') if err == io.EOF { break } if err != nil { return nil, err } line = strings.TrimRight(line, " \t\r\n") lines = append(lines, line) } // join lines, and replace multi-line descriptions with single line for each tmp := strings.Join(lines, "\n") re := regexp.MustCompile("\n ") n := re.ReplaceAllString(tmp, " ") //split lines again return strings.Split(n, "\n"), nil } func (c *Client) parseOutput(output []string) Result { var result Result var reachedRules bool for _, row := range output { // header if spamInfoRe.MatchString(row) { res := spamInfoRe.FindStringSubmatch(row) if len(res) == 5 { resCode, err := strconv.Atoi(res[3]) if err == nil { result.ResponseCode = resCode } result.Message = res[4] continue } } // summary if spamMainRe.MatchString(row) { res := spamMainRe.FindStringSubmatch(row) if len(res) == 4 { if tools.InArray(res[1], []string{"true", "yes"}) { result.Spam = true } else { result.Spam = false } resFloat, err := strconv.ParseFloat(res[2], 32) if err == nil { result.Score = resFloat continue } resFloat, err = strconv.ParseFloat(res[3], 32) if err == nil { result.Threshold = resFloat continue } } } if strings.HasPrefix(row, "Content analysis details") { reachedRules = true continue } // details if reachedRules && spamDetailsRe.MatchString(row) { res := spamDetailsRe.FindStringSubmatch(row) if len(res) == 5 { rule := Rule{Points: res[1], Name: res[2], Description: res[4]} result.Rules = append(result.Rules, rule) } } } return result } // Ping the spamd func (c *Client) Ping() error { conn, err := c.dial() if err != nil { return err } defer func() { _ = conn.Close() }() if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil { return err } if _, err := io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)); err != nil { return err } if err := conn.CloseWrite(); err != nil { return err } br := bufio.NewReader(conn) for { _, err = br.ReadSlice('\n') if err == io.EOF { break } if err != nil { return err } } return nil } ================================================ FILE: internal/stats/stats.go ================================================ // Package stats stores and returns Mailpit statistics package stats import ( "runtime" "sync" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" ) // Stores cached version along with its expiry time and error count. // Used to minimize repeated version lookups and track consecutive errors. type versionCache struct { // github version string value string // time to expire the cache expiry time.Time // count of consecutive errors errCount int } var ( // Version cache storing the latest GitHub version vCache versionCache // StartedAt is set to the current ime when Mailpit starts startedAt time.Time // sync mutex to prevent race condition with simultaneous requests mu sync.RWMutex smtpAccepted uint64 smtpAcceptedSize uint64 smtpRejected uint64 smtpIgnored uint64 ) // AppInformation struct // swagger:model AppInformation type AppInformation struct { // Current Mailpit version Version string // Latest Mailpit version LatestVersion string // Database path Database string // Database size in bytes DatabaseSize uint64 // Total number of messages in the database Messages uint64 // Total number of messages in the database Unread uint64 // Tags and message totals per tag Tags map[string]int64 // Runtime statistics RuntimeStats struct { // Mailpit server uptime in seconds Uptime uint64 // Current memory usage in bytes Memory uint64 // Database runtime messages deleted MessagesDeleted uint64 // Accepted runtime SMTP messages SMTPAccepted uint64 // Total runtime accepted messages size in bytes SMTPAcceptedSize uint64 // Rejected runtime SMTP messages SMTPRejected uint64 // Ignored runtime SMTP messages (when using --ignore-duplicate-ids) SMTPIgnored uint64 } } // Calculates exponential backoff duration based on the error count. func getBackoff(errCount int) time.Duration { backoff := min(time.Duration(1< 0 { total := totalMessagesSize() var deletedPercent float64 if total == 0 { deletedPercent = 100 } else { deletedPercent = float64(deletedSize * 100 / total) } // only vacuum the DB if at least 1% of mail storage size has been deleted if deletedPercent >= 1 { logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent) vacuumDb() } } } pruneMessages() } } // PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages. // Set config.MaxMessages to 0 to disable. func pruneMessages() { if config.MaxMessages < 1 && config.MaxAgeInHours == 0 { return } start := time.Now() ids := []string{} var prunedSize uint64 var size float64 // use float64 for rqlite compatibility // prune using `--max` if set if config.MaxMessages > 0 && CountTotal() > uint64(config.MaxMessages) { offset := config.MaxMessages if config.DemoMode { offset = 500 } q := sqlf.Select("ID, Size"). From(tenant("mailbox")). OrderBy("Created DESC"). Limit(5000). Offset(offset) if err := q.QueryAndClose( context.TODO(), db, func(row *sql.Rows) { var id string if err := row.Scan(&id, &size); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } ids = append(ids, id) prunedSize = prunedSize + uint64(size) }, ); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } } // prune using `--max-age` if set if config.MaxAgeInHours > 0 { // now() minus the number of hours ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli() q := sqlf.Select("ID, Size"). From(tenant("mailbox")). Where("Created < ?", ts). Limit(5000) if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var id string if err := row.Scan(&id, &size); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } if !tools.InArray(id, ids) { ids = append(ids, id) prunedSize = prunedSize + uint64(size) } }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } } if len(ids) == 0 { return } tx, err := db.BeginTx(context.Background(), nil) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } // roll back if it fails defer func() { _ = tx.Rollback() }() args := make([]any, len(ids)) for i, id := range ids { args[i] = id } _, err = tx.Exec(`DELETE FROM `+tenant("mailbox_data")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } _, err = tx.Exec(`DELETE FROM `+tenant("message_tags")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } _, err = tx.Exec(`DELETE FROM `+tenant("mailbox")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } if err = tx.Commit(); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } if err := pruneUnusedTags(); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } addDeletedSize(prunedSize) dbLastAction = time.Now() elapsed := time.Since(start) logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed) logMessagesDeleted(len(ids)) if config.DemoMode { vacuumDb() } websockets.Broadcast("prune", nil) } // Vacuum the database to reclaim space from deleted messages func vacuumDb() { if sqlDriver == "rqlite" { // let rqlite handle vacuuming return } start := time.Now() // set WAL file checkpoint if _, err := db.Exec("PRAGMA wal_checkpoint"); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } // vacuum database if _, err := db.Exec("VACUUM"); err != nil { logger.Log().Errorf("[db] VACUUM: %s", err.Error()) return } // truncate WAL file if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } if err := SettingPut("DeletedSize", "0"); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } elapsed := time.Since(start) logger.Log().Debugf("[db] vacuum completed in %s", elapsed) } ================================================ FILE: internal/storage/database.go ================================================ // Package storage handles all database actions package storage import ( "context" "database/sql" "fmt" "os" "os/signal" "path" "path/filepath" "strings" "syscall" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/klauspost/compress/zstd" "github.com/leporo/sqlf" // sqlite - https://gitlab.com/cznic/sqlite _ "modernc.org/sqlite" // rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/ _ "github.com/rqlite/gorqlite/stdlib" ) var ( db *sql.DB sqlDriver string dbLastAction time.Time // zstd compression encoder & decoder dbEncoder *zstd.Encoder dbDecoder, _ = zstd.NewReader(nil) temporaryFiles = []string{} ) // InitDB will initialise the database func InitDB() error { // dbEncoder var ( dsn string err error ) if config.Compression > 0 { var compression zstd.EncoderLevel switch config.Compression { case 1: compression = zstd.SpeedFastest case 2: compression = zstd.SpeedDefault case 3: compression = zstd.SpeedBestCompression } dbEncoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(compression)) if err != nil { return err } logger.Log().Debugf("[db] storing messages with compression: %s", compression.String()) } else { logger.Log().Debug("[db] storing messages with no compression") } p := config.Database if p == "" { // when no path is provided then we create a temporary file // which will get deleted on Close(), SIGINT or SIGTERM p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano()) // delete the Unix socket file on exit AddTempFile(p) sqlDriver = "sqlite" dsn = p logger.Log().Debugf("[db] using temporary database: %s", p) } else if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") { sqlDriver = "rqlite" dsn = p logger.Log().Debugf("[db] opening rqlite database %s", p) } else { p = filepath.Clean(p) sqlDriver = "sqlite" dsn = fmt.Sprintf("file:%s?cache=shared", p) logger.Log().Debugf("[db] opening database %s", p) } config.Database = p if sqlDriver == "sqlite" { if !isFile(p) { // try create a file to ensure permissions f, err := os.Create(p) if err != nil { return fmt.Errorf("[db] %s", err.Error()) } _ = f.Close() } } db, err = sql.Open(sqlDriver, dsn) if err != nil { return err } for i := 1; i < 6; i++ { if err := Ping(); err != nil { logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Infof("[db] reconnecting in 5 seconds (attempt %d/5)", i) time.Sleep(5 * time.Second) } else { break } } // prevent "database locked" errors // @see https://github.com/mattn/go-sqlite3#faq db.SetMaxOpenConns(1) if sqlDriver == "sqlite" { if config.DisableWAL { // disable WAL mode for SQLite, allows NFS mounted DBs _, err = db.Exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=NORMAL;") } else { // SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) _, err = db.Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") } if err != nil { return err } } // create tables if necessary & apply migrations if err := dbApplySchemas(); err != nil { return err } LoadTagFilters() dbLastAction = time.Now() sigs := make(chan os.Signal, 1) // catch all signals since not explicitly listing // Program that will listen to the SIGINT and SIGTERM // SIGINT will listen to CTRL-C. // SIGTERM will be caught if kill command executed signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) // method invoked upon seeing signal go func() { s := <-sigs fmt.Printf("[db] got %s signal, shutting down\n", s) Close() os.Exit(0) }() // auto-prune & delete go dbCron() go dataMigrations() return nil } // Tenant applies an optional prefix to the table name func tenant(table string) string { return fmt.Sprintf("%s%s", config.TenantID, table) } // Close will close the database, and delete if temporary func Close() { // on a fatal exit (eg: ports blocked), allow Mailpit to run migration tasks before closing the DB time.Sleep(200 * time.Millisecond) if db != nil { if err := db.Close(); err != nil { logger.Log().Warn("[db] error closing database, ignoring") } } // allow SQLite to finish closing DB & write WAL logs if local time.Sleep(100 * time.Millisecond) // delete all temporary files deleteTempFiles() } // Ping the database connection and return an error if unsuccessful func Ping() error { return db.Ping() } // StatsGet returns the total/unread statistics for a mailbox func StatsGet() MailboxStats { var ( total = CountTotal() unread = CountUnread() tags = GetAllTags() ) dbLastAction = time.Now() return MailboxStats{ Total: total, Unread: unread, Tags: tags, } } // CountTotal returns the number of emails in the database func CountTotal() uint64 { var total float64 // use float64 for rqlite compatibility _ = sqlf.From(tenant("mailbox")). Select("COUNT(*)").To(&total). QueryRowAndClose(context.TODO(), db) return uint64(total) } // CountUnread returns the number of emails in the database that are unread. func CountUnread() uint64 { var total float64 // use float64 for rqlite compatibility _ = sqlf.From(tenant("mailbox")). Select("COUNT(*)").To(&total). Where("Read = ?", 0). QueryRowAndClose(context.TODO(), db) return uint64(total) } // CountRead returns the number of emails in the database that are read. func CountRead() uint64 { var total float64 // use float64 for rqlite compatibility _ = sqlf.From(tenant("mailbox")). Select("COUNT(*)").To(&total). Where("Read = ?", 1). QueryRowAndClose(context.TODO(), db) return uint64(total) } // DbSize returns the size of the SQLite database. func DbSize() uint64 { var total sql.NullFloat64 // use float64 for rqlite compatibility err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) } return uint64(total.Float64) } // MessageIDExists checks whether a Message-ID exists in the DB func MessageIDExists(id string) bool { var total int _ = sqlf.From(tenant("mailbox")). Select("COUNT(*)").To(&total). Where("MessageID = ?", id). QueryRowAndClose(context.TODO(), db) return total != 0 } ================================================ FILE: internal/storage/functions_test.go ================================================ package storage import ( "fmt" "os" "testing" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" ) var ( testTextEmail []byte testTagEmail []byte testMimeEmail []byte testRuns = 100 ) func setup(tenantID string) { logger.NoLogging = true config.MaxMessages = 0 config.Database = os.Getenv("MP_DATABASE") config.TenantID = config.DBTenantID(tenantID) if err := InitDB(); err != nil { panic(err) } var err error // ensure DB is empty if err := DeleteAllMessages(); err != nil { panic(err) } testTextEmail, err = os.ReadFile("testdata/plain-text.eml") if err != nil { panic(err) } testTagEmail, err = os.ReadFile("testdata/tags.eml") if err != nil { panic(err) } testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml") if err != nil { panic(err) } } func assertEqual(t *testing.T, a any, b any, message string) { if a == b { return } message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b) t.Fatal(message) } func assertEqualStats(t *testing.T, total int, unread int) { s := StatsGet() if uint64(total) != s.Total { t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total) } if uint64(unread) != s.Unread { t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread) } } ================================================ FILE: internal/storage/messages.go ================================================ package storage import ( "bytes" "context" "crypto/md5" // #nosec "crypto/sha1" // #nosec "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/mail" "strings" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/webhook" "github.com/axllent/mailpit/server/websockets" "github.com/jhillyerd/enmime/v2" "github.com/leporo/sqlf" "github.com/lithammer/shortuuid/v4" ) // Store will save an email to the database tables. // The username is the authentication username of either the SMTP or HTTP client (blank for none). // Returns the database ID of the saved message. func Store(body *[]byte, username *string) (string, error) { parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) // Parse message body with enmime env, err := parser.ReadEnvelope(bytes.NewReader(*body)) if err != nil { logger.Log().Warnf("[message] %s", err.Error()) return "", nil } from := &mail.Address{} fromJSON := addressToSlice(env, "From") if len(fromJSON) > 0 { from = fromJSON[0] } else if env.GetHeader("From") != "" { from = &mail.Address{Name: env.GetHeader("From")} } obj := Metadata{ From: from, To: addressToSlice(env, "To"), Cc: addressToSlice(env, "Cc"), Bcc: addressToSlice(env, "Bcc"), ReplyTo: addressToSlice(env, "Reply-To"), } if username != nil { obj.Username = *username } messageID := strings.Trim(env.GetHeader("Message-ID"), "<>") created := time.Now() // use message date instead of created date if config.UseMessageDates { if mDate, err := env.Date(); err == nil { created = mDate } } // generate the search text searchText := createSearchText(env) // generate unique ID id := shortuuid.New() summaryJSON, err := json.Marshal(obj) if err != nil { return "", err } // begin a transaction to ensure both the message // and data are stored successfully ctx := context.Background() tx, err := db.BeginTx(ctx, nil) if err != nil { return "", err } // roll back if it fails defer func() { _ = tx.Rollback() }() subject := env.GetHeader("Subject") size := uint64(len(*body)) inline := len(env.Inlines) attachments := len(env.Attachments) snippet := tools.CreateSnippet(env.Text, env.HTML) sql := fmt.Sprintf(`INSERT INTO %s (Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) VALUES(?,?,?,?,?,?,?,?,?,0,?)`, tenant("mailbox"), ) // #nosec // insert mail summary data _, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet) if err != nil { return "", err } if config.Compression > 0 { // insert compressed raw message compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size)) if sqlDriver == "rqlite" { // rqlite does not support binary data in query, so we need to encode the compressed message into hexadecimal // string and then generate the SQL query, which is more memory intensive, especially with large messages hexStr := hex.EncodeToString(compressed) _, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, x'%s', 1)`, tenant("mailbox_data"), hexStr), id) // #nosec } else { _, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 1)`, tenant("mailbox_data")), id, compressed) // #nosec } } else { // insert uncompressed raw message _, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email, Compressed) VALUES(?, ?, 0)`, tenant("mailbox_data")), id, string(*body)) // #nosec } if err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } // extract tags using pre-set tag filters, empty slice if not set tags := findTagsInRawMessage(body) if !config.TagsDisableXTags { xTagsHdr := env.GetHeader("X-Tags") if xTagsHdr != "" { // extract tags from X-Tags header tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...) } } if !config.TagsDisablePlus { // get tags from plus-addresses tags = append(tags, obj.tagsFromPlusAddresses()...) } // auto-tag by username if enabled if config.TagsUsername && username != nil && *username != "" { tags = append(tags, *username) } // extract tags from search matches, and sort and extract unique tags tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...)) setTags := []string{} if len(tags) > 0 { setTags, err = SetMessageTags(id, tags) if err != nil { return "", err } } c := &MessageSummary{} if err := json.Unmarshal(summaryJSON, c); err != nil { return "", err } // we do not want to to broadcast null values for MetaData else this does not align // with the message summary documented in the API docs, so we set them to empty slices. if c.From == nil { c.From = &mail.Address{} } if c.To == nil { c.To = []*mail.Address{} } if c.Cc == nil { c.Cc = []*mail.Address{} } if c.Bcc == nil { c.Bcc = []*mail.Address{} } if c.ReplyTo == nil { c.ReplyTo = []*mail.Address{} } c.Created = created c.ID = id c.MessageID = messageID c.Attachments = attachments c.Subject = subject c.Size = size c.Tags = setTags c.Snippet = snippet websockets.Broadcast("new", c) webhook.Send(c) dbLastAction = time.Now() BroadcastMailboxStats() logger.Log().Debugf("[db] saved message %s (%d bytes)", id, size) return id, nil } // List returns a subset of messages from the mailbox, // sorted latest to oldest func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) { results := []MessageSummary{} tsStart := time.Now() q := sqlf.From(tenant("mailbox") + " m"). Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`). OrderBy("m.Created DESC") if limit > 0 { q = q.Limit(limit).Offset(start) } if beforeTS > 0 { q = q.Where("Created < ?", beforeTS) } if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var created float64 // use float64 for rqlite compatibility var id string var messageID string var subject string var metadataJSON string var size float64 // use float64 for rqlite compatibility var attachments int var read int var snippet string em := MessageSummary{} var meta Metadata err := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil { logger.Log().Errorf("[json] %s", err.Error()) return } em.From = meta.From em.To = meta.To em.Cc = meta.Cc em.Bcc = meta.Bcc em.ReplyTo = meta.ReplyTo em.Username = meta.Username em.Created = time.UnixMilli(int64(created)) em.ID = id em.MessageID = messageID em.Subject = subject em.Size = uint64(size) em.Attachments = attachments em.Read = read == 1 em.Snippet = snippet // artificially generate ReplyTo if legacy data is missing Reply-To field if em.ReplyTo == nil { em.ReplyTo = []*mail.Address{} } results = append(results, em) }); err != nil { return results, err } // set tags for listed messages only for i, m := range results { results[i].Tags = getMessageTags(m.ID) } dbLastAction = time.Now() elapsed := time.Since(tsStart) logger.Log().Debugf("[db] list INBOX in %s", elapsed) return results, nil } // GetMessage returns a Message generated from the mailbox_data collection. // If the message lacks a date header, then the received datetime is used. func GetMessage(id string) (*Message, error) { raw, err := GetMessageRaw(id) if err != nil { return nil, err } r := bytes.NewReader(raw) parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) env, err := parser.ReadEnvelope(r) if err != nil { return nil, err } // Load metadata from DB meta, err := GetMetadata(id) if err != nil { meta = Metadata{} } from := meta.From if from == nil { fromData := addressToSlice(env, "From") if len(fromData) > 0 { from = fromData[0] } else if env.GetHeader("From") != "" { from = &mail.Address{Name: env.GetHeader("From")} } } messageID := strings.Trim(env.GetHeader("Message-ID"), "<>") returnPath := strings.Trim(env.GetHeader("Return-Path"), "<>") if returnPath == "" && from != nil { returnPath = from.Address } date, err := env.Date() if err != nil { // return received datetime when message does not contain a date header q := sqlf.From(tenant("mailbox")). Select(`Created`). Where(`ID = ?`, id) if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var created float64 // use float64 for rqlite compatibility if err := row.Scan(&created); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id) date = time.UnixMilli(int64(created)) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } } obj := Message{ ID: id, MessageID: messageID, From: from, Date: date, To: addressToSlice(env, "To"), Cc: addressToSlice(env, "Cc"), Bcc: addressToSlice(env, "Bcc"), ReplyTo: addressToSlice(env, "Reply-To"), ReturnPath: returnPath, Subject: env.GetHeader("Subject"), Tags: getMessageTags(id), Size: uint64(len(raw)), Text: env.Text, Username: meta.Username, } obj.HTML = env.HTML obj.Inline = []Attachment{} obj.Attachments = []Attachment{} for _, i := range env.Inlines { if i.FileName != "" || i.ContentID != "" { obj.Inline = append(obj.Inline, AttachmentSummary(i)) } } for _, i := range env.OtherParts { if i.FileName != "" || i.ContentID != "" { obj.Inline = append(obj.Inline, AttachmentSummary(i)) } } for _, a := range env.Attachments { if a.FileName != "" || a.ContentID != "" { obj.Attachments = append(obj.Attachments, AttachmentSummary(a)) } } // get List-Unsubscribe links if set obj.ListUnsubscribe = ListUnsubscribe{} obj.ListUnsubscribe.Links = []string{} if env.GetHeader("List-Unsubscribe") != "" { l := env.GetHeader("List-Unsubscribe") links, err := tools.ListUnsubscribeParser(l) obj.ListUnsubscribe.Header = l obj.ListUnsubscribe.Links = links if err != nil { obj.ListUnsubscribe.Errors = err.Error() } obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post") } // mark message as read if err := MarkRead([]string{id}); err != nil { return &obj, err } dbLastAction = time.Now() return &obj, nil } // GetMessageRaw returns an []byte of the full message func GetMessageRaw(id string) ([]byte, error) { var i, msg string var compressed int q := sqlf.From(tenant("mailbox_data")). Select(`ID`).To(&i). Select(`Email`).To(&msg). Select(`Compressed`).To(&compressed). Where(`ID = ?`, id) err := q.QueryRowAndClose(context.Background(), db) if err != nil { return nil, err } if i == "" { return nil, errors.New("message not found") } var data []byte if sqlDriver == "rqlite" && compressed == 1 { data, err = base64.StdEncoding.DecodeString(msg) if err != nil { return nil, fmt.Errorf("error decoding base64 message: %w", err) } } else { data = []byte(msg) } dbLastAction = time.Now() if compressed == 1 { raw, err := dbDecoder.DecodeAll(data, nil) if err != nil { return nil, fmt.Errorf("error decompressing message: %s", err.Error()) } return raw, err } return data, nil } // GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message func GetAttachmentPart(id, partID string) (*enmime.Part, error) { raw, err := GetMessageRaw(id) if err != nil { return nil, err } r := bytes.NewReader(raw) parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) env, err := parser.ReadEnvelope(r) if err != nil { return nil, err } for _, a := range env.Inlines { if a.PartID == partID { return a, nil } } for _, a := range env.OtherParts { if a.PartID == partID { return a, nil } } for _, a := range env.Attachments { if a.PartID == partID { return a, nil } } dbLastAction = time.Now() return nil, errors.New("attachment not found") } // AttachmentSummary returns a summary of the attachment without any binary data func AttachmentSummary(a *enmime.Part) Attachment { o := Attachment{} o.PartID = a.PartID o.FileName = a.FileName if o.FileName == "" { o.FileName = a.ContentID } o.ContentType = a.ContentType o.ContentID = a.ContentID o.Size = uint64(len(a.Content)) md5Hash := md5.Sum(a.Content) // #nosec sha1Hash := sha1.Sum(a.Content) // #nosec sha256Hash := sha256.Sum256(a.Content) o.Checksums.MD5 = hex.EncodeToString(md5Hash[:]) o.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:]) o.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:]) return o } // LatestID returns the latest message ID // // If a query argument is set in the request the function will return the // latest message matching the search func LatestID(r *http.Request) (string, error) { var messages []MessageSummary var err error search := strings.TrimSpace(r.URL.Query().Get("query")) if search != "" { messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1) if err != nil { return "", err } } else { messages, err = List(0, 0, 1) if err != nil { return "", err } } if len(messages) == 0 { return "", errors.New("Message not found") } return messages[0].ID, nil } // MarkRead will mark a message as read func MarkRead(ids []string) error { for _, id := range ids { _, err := sqlf.Update(tenant("mailbox")). Set("Read", 1). Where("ID = ?", id). ExecAndClose(context.Background(), db) if err == nil { logger.Log().Debugf("[db] marked message %s as read", id) } d := struct { ID string Read bool }{ID: id, Read: true} websockets.Broadcast("update", d) } BroadcastMailboxStats() return nil } // MarkAllRead will mark all messages as read func MarkAllRead() error { var ( start = time.Now() total = CountUnread() ) _, err := sqlf.Update(tenant("mailbox")). Set("Read", 1). Where("Read = ?", 0). ExecAndClose(context.Background(), db) if err != nil { return err } elapsed := time.Since(start) logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed) BroadcastMailboxStats() dbLastAction = time.Now() return nil } // MarkAllUnread will mark all messages as unread func MarkAllUnread() error { var ( start = time.Now() total = CountRead() ) _, err := sqlf.Update(tenant("mailbox")). Set("Read", 0). Where("Read = ?", 1). ExecAndClose(context.Background(), db) if err != nil { return err } elapsed := time.Since(start) logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed) BroadcastMailboxStats() dbLastAction = time.Now() return nil } // MarkUnread will mark a message as unread func MarkUnread(ids []string) error { for _, id := range ids { _, err := sqlf.Update(tenant("mailbox")). Set("Read", 0). Where("ID = ?", id). ExecAndClose(context.Background(), db) if err == nil { logger.Log().Debugf("[db] marked message %s as unread", id) } dbLastAction = time.Now() d := struct { ID string Read bool }{ID: id, Read: false} websockets.Broadcast("update", d) } BroadcastMailboxStats() return nil } // DeleteMessages deletes one or more messages in bulk func DeleteMessages(ids []string) error { if len(ids) == 0 { return nil } start := time.Now() args := make([]any, len(ids)) for i, id := range ids { args[i] = id } sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec rows, err := db.Query(sql, args...) if err != nil { return err } defer func() { _ = rows.Close() }() toDelete := []string{} var totalSize uint64 for rows.Next() { var id string var size float64 // use float64 for rqlite compatibility if err := rows.Scan(&id, &size); err != nil { return err } toDelete = append(toDelete, id) totalSize = totalSize + uint64(size) } if err = rows.Err(); err != nil { return err } if len(toDelete) == 0 { return nil // nothing to delete } tx, err := db.BeginTx(context.Background(), nil) if err != nil { return err } args = make([]any, len(toDelete)) for i, id := range toDelete { args[i] = id } tables := []string{"mailbox", "mailbox_data", "message_tags"} for _, t := range tables { sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1)) _, err = tx.Exec(sql, args...) // #nosec if err != nil { return err } } if err := tx.Commit(); err != nil { return err } dbLastAction = time.Now() addDeletedSize(totalSize) logMessagesDeleted(len(toDelete)) _ = pruneUnusedTags() elapsed := time.Since(start) messages := "messages" if len(toDelete) == 1 { messages = "message" } logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed) BroadcastMailboxStats() // broadcast individual message deletions for _, id := range toDelete { d := struct { ID string }{ID: id} websockets.Broadcast("delete", d) } return nil } // DeleteAllMessages will delete all messages from a mailbox func DeleteAllMessages() error { var ( start = time.Now() total int ) _ = sqlf.From(tenant("mailbox")). Select("COUNT(*)").To(&total). QueryRowAndClose(context.TODO(), db) // begin a transaction to ensure both the message // summaries and data are deleted successfully tx, err := db.BeginTx(context.Background(), nil) if err != nil { return err } // roll back if it fails defer func() { _ = tx.Rollback() }() tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"} for _, t := range tables { sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec _, err := tx.Exec(sql) if err != nil { return err } } if err := tx.Commit(); err != nil { return err } elapsed := time.Since(start) logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed) vacuumDb() dbLastAction = time.Now() if err := SettingPut("DeletedSize", "0"); err != nil { logger.Log().Warnf("[db] %s", err.Error()) } logMessagesDeleted(total) BroadcastMailboxStats() websockets.Broadcast("truncate", nil) return err } // GetMetadata retrieves the metadata for a message by its ID func GetMetadata(id string) (Metadata, error) { var metadataJSON string row := db.QueryRow(fmt.Sprintf("SELECT Metadata FROM %s WHERE ID = ?", tenant("mailbox")), id) if err := row.Scan(&metadataJSON); err != nil { return Metadata{}, err } var meta Metadata if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil { return Metadata{}, err } return meta, nil } ================================================ FILE: internal/storage/messages_test.go ================================================ package storage import ( "os" "testing" "time" "github.com/axllent/mailpit/config" ) func TestTextEmailInserts(t *testing.T) { setup("") defer Close() t.Log("Testing text email storage") start := time.Now() for range testRuns { if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } } assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of text emails stored") t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start)) delStart := time.Now() if err := DeleteAllMessages(); err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, CountTotal(), uint64(0), "incorrect number of text emails deleted") t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart)) assertEqualStats(t, 0, 0) } func TestMimeEmailInserts(t *testing.T) { for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) setup(tenantID) if tenantID == "" { t.Log("Testing mime email storage") } else { t.Logf("Testing mime email storage (tenant %s)", tenantID) } start := time.Now() for range testRuns { if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } } assertEqual(t, CountTotal(), uint64(testRuns), "Incorrect number of mime emails stored") t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start)) delStart := time.Now() if err := DeleteAllMessages(); err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, CountTotal(), uint64(0), "incorrect number of mime emails deleted") t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart)) Close() } } func TestRetrieveMimeEmail(t *testing.T) { compressionLevels := []int{0, 1, 2, 3} for _, compressionLevel := range compressionLevels { t.Logf("Testing compression level: %d", compressionLevel) for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) config.Compression = compressionLevel setup(tenantID) if tenantID == "" { t.Log("Testing mime email retrieval") } else { t.Logf("Testing mime email retrieval (tenant %s)", tenantID) } id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() } msg, err := GetMessage(id) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match") assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match") assertEqual(t, msg.Subject, "inline + attachment", "subject does not match") assertEqual(t, len(msg.To), 1, "incorrect number of recipients") assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match") assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match") assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments") assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match") assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments") assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match") attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, uint64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match") inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, uint64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match") Close() } } // reset compression config.Compression = 1 } func TestMessageSummary(t *testing.T) { for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) setup(tenantID) if tenantID == "" { t.Log("Testing message summary") } else { t.Logf("Testing message summary (tenant %s)", tenantID) } if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } summaries, err := List(0, 0, 1) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, len(summaries), 1, "Expected 1 result") msg := summaries[0] assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match") assertEqual(t, msg.From.Address, "sender2@example.com", "\"From\" address does not match") assertEqual(t, msg.Subject, "inline + attachment", "subject does not match") assertEqual(t, len(msg.To), 1, "incorrect number of recipients") assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match") assertEqual(t, msg.To[0].Address, "recipient2@example.com", "\"To\" address does not match") assertEqual(t, msg.Snippet, "Message with inline image and attachment:", "\"Snippet\" does does not match") assertEqual(t, msg.Attachments, 1, "Expected 1 attachment") assertEqual(t, msg.MessageID, "33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com", "\"MessageID\" does not match") Close() } } func BenchmarkImportText(b *testing.B) { setup("") defer Close() for i := 0; i < b.N; i++ { if _, err := Store(&testTextEmail, nil); err != nil { b.Log("error ", err) b.Fail() } } } func BenchmarkImportMime(b *testing.B) { setup("") defer Close() for i := 0; i < b.N; i++ { if _, err := Store(&testMimeEmail, nil); err != nil { b.Log("error ", err) b.Fail() } } } func TestInlineImageContentIdHandling(t *testing.T) { setup("") defer Close() t.Log("Testing inline content handling") // Test case: Proper inline image with Content-Disposition: inline inlineAttachment, err := os.ReadFile("testdata/inline-attachment.eml") if err != nil { t.Fatalf("Failed to read test email: %v", err) } storedMessage, err := Store(&inlineAttachment, nil) if err != nil { t.Fatal("Failed to store test case 1:", err) } msg, err := GetMessage(storedMessage) if err != nil { t.Fatal("Failed to retrieve test case 1:", err) } // Assert if len(msg.Inline) != 1 { t.Errorf("Test case 1: Expected 1 inline attachment, got %d", len(msg.Inline)) } if len(msg.Attachments) != 0 { t.Errorf("Test case 1: Expected 0 regular attachments, got %d", len(msg.Attachments)) } if msg.Inline[0].ContentID != "test1@example.com" { t.Errorf("Test case 1: Expected ContentID 'test1@example.com', got '%s'", msg.Inline[0].ContentID) } } func TestRegularAttachmentHandling(t *testing.T) { setup("") defer Close() t.Log("Testing regular attachment handling") // Test case: Regular attachment without Content-ID regularAttachment, err := os.ReadFile("testdata/regular-attachment.eml") if err != nil { t.Fatalf("Failed to read test email: %v", err) } storedMessage, err := Store(®ularAttachment, nil) if err != nil { t.Fatal("Failed to store test case 3:", err) } msg, err := GetMessage(storedMessage) if err != nil { t.Fatal("Failed to retrieve test case 3:", err) } // Assert if len(msg.Inline) != 0 { t.Errorf("Test case 3: Expected 0 inline attachments, got %d", len(msg.Inline)) } if len(msg.Attachments) != 1 { t.Errorf("Test case 3: Expected 1 regular attachment, got %d", len(msg.Attachments)) } if msg.Attachments[0].ContentID != "" { t.Errorf("Test case 3: Expected empty ContentID, got '%s'", msg.Attachments[0].ContentID) } // Checksum tests assertEqual(t, msg.Attachments[0].Checksums.MD5, "b04930eb1ba0c62066adfa87e5d262c4", "Attachment MD5 checksum does not match") assertEqual(t, msg.Attachments[0].Checksums.SHA1, "15605d6a2fca44e966209d1701f16ecf816df880", "Attachment SHA1 checksum does not match") assertEqual(t, msg.Attachments[0].Checksums.SHA256, "92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec", "Attachment SHA256 checksum does not match") } func TestMixedAttachmentHandling(t *testing.T) { setup("") defer Close() t.Log("Testing mixed attachment handling") // Mixed scenario with both inline and regular attachment mixedAttachment, err := os.ReadFile("testdata/mixed-attachment.eml") if err != nil { t.Fatalf("Failed to read test email: %v", err) } storedMessage, err := Store(&mixedAttachment, nil) if err != nil { t.Fatal("Failed to store test case 4:", err) } msg, err := GetMessage(storedMessage) if err != nil { t.Fatal("Failed to retrieve test case 4:", err) } // Assert: Should have 1 inline (with ContentID) and 1 attachment (without ContentID) if len(msg.Inline) != 1 { t.Errorf("Test case 4: Expected 1 inline attachment, got %d", len(msg.Inline)) } if len(msg.Attachments) != 1 { t.Errorf("Test case 4: Expected 1 regular attachment, got %d", len(msg.Attachments)) } if msg.Inline[0].ContentID != "inline@example.com" { t.Errorf("Test case 4: Expected inline ContentID 'inline@example.com', got '%s'", msg.Inline[0].ContentID) } if msg.Attachments[0].ContentID != "" { t.Errorf("Test case 4: Expected attachment ContentID to be empty, got '%s'", msg.Attachments[0].ContentID) } } ================================================ FILE: internal/storage/notifications.go ================================================ package storage import ( "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/server/websockets" ) var bcStatsDelay = false // BroadcastMailboxStats broadcasts the total number of messages // displayed to the web UI, as well as the total unread messages. // The lookup is very fast (< 10ms / 100k messages under load). // Rate limited to 4x per second. func BroadcastMailboxStats() { if bcStatsDelay { return } bcStatsDelay = true go func() { time.Sleep(250 * time.Millisecond) bcStatsDelay = false b := struct { Total uint64 Unread uint64 Version string }{ Total: CountTotal(), Unread: CountUnread(), Version: config.Version, } websockets.Broadcast("stats", b) }() } ================================================ FILE: internal/storage/reindex.go ================================================ package storage import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "net/mail" "os" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/jhillyerd/enmime/v2" "github.com/leporo/sqlf" ) // ReindexAll will regenerate the search text and snippet for a message // and update the database. func ReindexAll() { ids := []string{} var i string chunkSize := 1000 finished := 0 err := sqlf.Select("ID").To(&i). From(tenant("mailbox")). OrderBy("Created DESC"). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { ids = append(ids, i) }) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) os.Exit(1) } total := len(ids) chunks := chunkBy(ids, chunkSize) logger.Log().Infof("reindexing %d messages", total) type updateStruct struct { // ID in database ID string // SearchText for searching SearchText string // Snippet for UI Snippet string // Metadata info Metadata string } parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) for _, ids := range chunks { updates := []updateStruct{} for _, id := range ids { raw, err := GetMessageRaw(id) if err != nil { logger.Log().Error(err) continue } r := bytes.NewReader(raw) env, err := parser.ReadEnvelope(r) if err != nil { logger.Log().Errorf("[message] %s", err.Error()) continue } meta, _ := GetMetadata(id) fromJSON := addressToSlice(env, "From") if len(fromJSON) > 0 { meta.From = fromJSON[0] } else if env.GetHeader("From") != "" { meta.From = &mail.Address{Name: env.GetHeader("From")} } else { meta.From = nil } meta.To = addressToSlice(env, "To") meta.Cc = addressToSlice(env, "Cc") meta.Bcc = addressToSlice(env, "Bcc") meta.ReplyTo = addressToSlice(env, "Reply-To") MetadataJSON, err := json.Marshal(meta) if err != nil { logger.Log().Errorf("[message] %s", err.Error()) continue } searchText := createSearchText(env) snippet := tools.CreateSnippet(env.Text, env.HTML) u := updateStruct{} u.ID = id u.SearchText = searchText u.Snippet = snippet u.Metadata = string(MetadataJSON) updates = append(updates, u) } ctx := context.Background() tx, err := db.BeginTx(ctx, nil) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) continue } // roll back if it fails defer func() { _ = tx.Rollback() }() // insert mail summary data for _, u := range updates { _, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) continue } } if err := tx.Commit(); err != nil { logger.Log().Errorf("[db] %s", err.Error()) continue } finished += len(updates) logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total) } } func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) { for chunkSize < len(items) { items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize]) } return append(chunks, items) } ================================================ FILE: internal/storage/schemas/1.0.0.sql ================================================ -- CREATE TABLES CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} ( Sort INTEGER PRIMARY KEY AUTOINCREMENT, ID TEXT NOT NULL, Data BLOB, Search TEXT, Read INTEGER ); CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort); CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID); CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read); CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} ( ID TEXT KEY NOT NULL, Email BLOB ); CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID); ================================================ FILE: internal/storage/schemas/1.1.0.sql ================================================ -- CREATE TAGS COLUMN ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]'; CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags); ================================================ FILE: internal/storage/schemas/1.2.0.sql ================================================ -- CREATING NEW MAILBOX FORMAT CREATE TABLE IF NOT EXISTS {{ tenant "mailboxtmp" }} ( Created INTEGER NOT NULL, ID TEXT NOT NULL, MessageID TEXT NOT NULL, Subject TEXT NOT NULL, Metadata TEXT, Size INTEGER NOT NULL, Inline INTEGER NOT NULL, Attachments INTEGER NOT NULL, Read INTEGER, Tags TEXT, SearchText TEXT ); INSERT INTO {{ tenant "mailboxtmp" }} (Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags) SELECT Sort, ID, '', json_extract(Data, '$.Subject'),Data, json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'), Search, Read, Tags FROM {{ tenant "mailbox" }}; DROP TABLE IF EXISTS {{ tenant "mailbox" }}; ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }}; CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created); CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID); CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID); CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject); CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size); CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline); CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments); CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read); CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags); ================================================ FILE: internal/storage/schemas/1.21.2.sql ================================================ -- DROP LEGACY MIGRATION TABLE DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }}; -- DROP LEGACY TAGS COLUMN DROP INDEX IF EXISTS {{ tenant "idx_tags" }}; ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags; ================================================ FILE: internal/storage/schemas/1.21.8.sql ================================================ -- Rebuild message_tags to remove FOREIGN KEY REFERENCES PRAGMA foreign_keys=OFF; DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }}; DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }}; ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old; CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} ( Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ID TEXT NOT NULL, TagID INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID); CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID); INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old; DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old; PRAGMA foreign_keys=ON; ================================================ FILE: internal/storage/schemas/1.23.0.sql ================================================ -- CREATE Compressed COLUMN IN mailbox_data ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0'; -- SET Compressed = 1 for all existing data UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1; ================================================ FILE: internal/storage/schemas/1.3.0.sql ================================================ -- CREATE SNIPPET COLUMN ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT ''; ================================================ FILE: internal/storage/schemas/1.4.0.sql ================================================ -- CREATE TAG TABLES CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} ( ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Name TEXT COLLATE NOCASE ); CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name); CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} ( Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ID TEXT REFERENCES {{ tenant "mailbox" }} (ID), TagID INT REFERENCES {{ tenant "tags" }} (ID) ); CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID); CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID); ================================================ FILE: internal/storage/schemas/1.5.0.sql ================================================ -- CREATE SETTINGS TABLE CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} ( Key TEXT, Value TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key); INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }})); ================================================ FILE: internal/storage/schemas/README.md ================================================ # Migration scripts - Scripts should be named using semver and have the `.sql` extension. - Inline comments should be prefixed with a `--` - All references to tables and indexes should be wrapped with a `{{ tenant "" }}` ================================================ FILE: internal/storage/schemas.go ================================================ package storage import ( "bytes" "embed" "log" "path" "sort" "strings" "text/template" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/semver" ) //go:embed schemas/* var schemaScripts embed.FS // Create tables and apply schemas if required func dbApplySchemas() error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil { return err } var legacyMigrationTable int err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable) if err != nil { return err } if legacyMigrationTable == 1 { rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations")) if err != nil { return err } legacySchemas := []string{} for rows.Next() { var oldID string if err := rows.Scan(&oldID); err == nil { legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID)) } } legacySchemas = semver.SortMin(legacySchemas) for _, v := range legacySchemas { var migrated int err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated) if err != nil { return err } if migrated == 0 { // copy to tenant("schemas") if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil { return err } } } } schemaFiles, err := schemaScripts.ReadDir("schemas") if err != nil { log.Fatal(err) } temp := template.New("") temp.Funcs( template.FuncMap{ "tenant": tenant, }, ) type schema struct { Name string Semver string } scripts := []schema{} for _, s := range schemaFiles { if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") { continue } schemaID := strings.TrimRight(s.Name(), ".sql") if !semver.IsValid(schemaID) { logger.Log().Warnf("[db] invalid schema name: %s", s.Name()) continue } script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)} scripts = append(scripts, script) } // sort schemas by semver, low to high sort.Slice(scripts, func(i, j int) bool { return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1 }) for _, s := range scripts { var complete int err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete) if err != nil { return err } if complete == 1 { // already completed, ignore continue } // use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305 b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name)) if err != nil { return err } // parse import script t1, err := temp.Parse(string(b)) if err != nil { return err } buf := new(bytes.Buffer) if err := t1.Execute(buf, nil); err != nil { return err } if _, err := db.Exec(buf.String()); err != nil { return err } if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil { return err } logger.Log().Debugf("[db] applied schema: %s", s.Name) } return nil } // These functions are used to migrate data formats/structure on startup. func dataMigrations() { // ensure DeletedSize has a value if empty if SettingGet("DeletedSize") == "" { _ = SettingPut("DeletedSize", "0") } } ================================================ FILE: internal/storage/search.go ================================================ package storage import ( "context" "database/sql" "encoding/json" "fmt" "regexp" "strconv" "strings" "time" "github.com/araddon/dateparse" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/websockets" "github.com/leporo/sqlf" ) // Search will search a mailbox for search terms. // The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // is:read, is:unread, has:attachment, to:, from: & subject: // Negative searches also also included by prefixing the search term with a `-` or `!` func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) { results := []MessageSummary{} allResults := []MessageSummary{} tsStart := time.Now() nrResults := 0 if limit < 0 { limit = 50 } q := searchQueryBuilder(search, timezone) if beforeTS > 0 { q = q.Where(`Created < ?`, beforeTS) } var err error if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var created float64 // use float64 for rqlite compatibility var id string var messageID string var subject string var metadata string var size float64 // use float64 for rqlite compatibility var attachments int var snippet string var read int var ignore string em := MessageSummary{} if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } if err := json.Unmarshal([]byte(metadata), &em); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } em.Created = time.UnixMilli(int64(created)) em.ID = id em.MessageID = messageID em.Subject = subject em.Size = uint64(size) em.Attachments = attachments em.Read = read == 1 em.Snippet = snippet allResults = append(allResults, em) }); err != nil { return results, nrResults, err } dbLastAction = time.Now() nrResults = len(allResults) if nrResults > start { end := min(nrResults, start+limit) results = allResults[start:end] } // set tags for listed messages only for i, m := range results { results[i].Tags = getMessageTags(m.ID) } elapsed := time.Since(tsStart) logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed) return results, nrResults, err } // SearchUnreadCount returns the number of unread messages matching a search. // This is run one at a time to allow connected browsers to be updated. func SearchUnreadCount(search, timezone string, beforeTS int64) (int64, error) { tsStart := time.Now() q := searchQueryBuilder(search, timezone) if beforeTS > 0 { q = q.Where(`Created < ?`, beforeTS) } var unread float64 // use float64 for rqlite compatibility q = q.Where("Read = 0").Select(`COUNT(*)`) err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var ignore sql.NullString if err := row.Scan(&ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &unread); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } }) dbLastAction = time.Now() elapsed := time.Since(tsStart) logger.Log().Debugf("[db] counted %d unread for \"%s\" in %s", int64(unread), search, elapsed) return int64(unread), err } // DeleteSearch will delete all messages for search terms. // The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // is:read, is:unread, has:attachment, to:, from: & subject: // Negative searches also also included by prefixing the search term with a `-` or `!` func DeleteSearch(search, timezone string) error { q := searchQueryBuilder(search, timezone) ids := []string{} deleteSize := uint64(0) if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var created float64 // use float64 for rqlite compatibility var id string var messageID string var subject string var metadata string var size float64 // use float64 for rqlite compatibility var attachments int var read int var snippet string var ignore string if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } ids = append(ids, id) deleteSize = deleteSize + uint64(size) }); err != nil { return err } if len(ids) > 0 { total := len(ids) // split ids into chunks of 1000 ids var chunks [][]string if total > 1000 { chunkSize := 1000 chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize) for chunkSize < len(ids) { ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize]) } if len(ids) > 0 { // add remaining ids <= 1000 chunks = append(chunks, ids) } } else { chunks = append(chunks, ids) } // begin a transaction to ensure both the message // and data are deleted successfully tx, err := db.BeginTx(context.Background(), nil) if err != nil { return err } // roll back if it fails defer func() { _ = tx.Rollback() }() for _, ids := range chunks { delIDs := make([]any, len(ids)) for i, id := range ids { delIDs[i] = id } sqlDelete1 := `DELETE FROM ` + tenant("mailbox") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec _, err = tx.Exec(sqlDelete1, delIDs...) if err != nil { return err } sqlDelete2 := `DELETE FROM ` + tenant("mailbox_data") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec _, err = tx.Exec(sqlDelete2, delIDs...) if err != nil { return err } sqlDelete3 := `DELETE FROM ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec _, err = tx.Exec(sqlDelete3, delIDs...) if err != nil { return err } } if err := tx.Commit(); err != nil { return err } if err := pruneUnusedTags(); err != nil { return err } logger.Log().Debugf("[db] deleted %d messages matching %s", total, search) dbLastAction = time.Now() // broadcast changes if len(ids) > 200 { websockets.Broadcast("prune", nil) } else { for _, id := range ids { d := struct { ID string }{ID: id} websockets.Broadcast("delete", d) } } addDeletedSize(deleteSize) logMessagesDeleted(total) BroadcastMailboxStats() } return nil } // SetSearchReadStatus marks all messages matching the search as read or unread func SetSearchReadStatus(search, timezone string, read bool) error { q := searchQueryBuilder(search, timezone).Where("Read = ?", !read) ids := []string{} if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var created float64 // use float64 for rqlite compatibility var id string var messageID string var subject string var metadata string var size float64 // use float64 for rqlite compatibility var attachments int var read int var snippet string var ignore string if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } ids = append(ids, id) }); err != nil { return err } if read { if err := MarkRead(ids); err != nil { return err } } else { if err := MarkUnread(ids); err != nil { return err } } return nil } // SearchParser returns the SQL syntax for the database search based on the search arguments func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt { // group strings with quotes as a single argument and remove quotes args := tools.ArgsParser(searchString) loc := time.Local if timezone != "" { if l, err := time.LoadLocation(timezone); err != nil { logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone) } else { loc = l } } q := sqlf.From(tenant("mailbox") + " m"). Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet, IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON, IFNULL(json_extract(Metadata, '$.From'), '{}') as FromJSON, IFNULL(json_extract(Metadata, '$.Cc'), '{}') as CcJSON, IFNULL(json_extract(Metadata, '$.Bcc'), '{}') as BccJSON, IFNULL(json_extract(Metadata, '$.ReplyTo'), '{}') as ReplyToJSON `). OrderBy("m.Created DESC") for _, w := range args { if cleanString(w) == "" { continue } // lowercase search to try match search prefixes lw := strings.ToLower(w) exclude := false // search terms starting with a `-` or `!` imply an exclude if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) { exclude = true w = w[1:] lw = lw[1:] } // ignore blank searches if len(w) == 0 { continue } if strings.HasPrefix(lw, "to:") { w = cleanString(w[3:]) if w != "" { if exclude { q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "from:") { w = cleanString(w[5:]) if w != "" { if exclude { q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "cc:") { w = cleanString(w[3:]) if w != "" { if exclude { q.Where("CcJSON NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("CcJSON LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "bcc:") { w = cleanString(w[4:]) if w != "" { if exclude { q.Where("BccJSON NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("BccJSON LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "reply-to:") { w = cleanString(w[9:]) if w != "" { if exclude { q.Where("ReplyToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "addressed:") { w = cleanString(w[10:]) arg := "%" + escPercentChar(w) + "%" if w != "" { if exclude { q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg) } else { q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg) } } } else if strings.HasPrefix(lw, "subject:") { w = w[8:] if w != "" { if exclude { q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "message-id:") { w = cleanString(w[11:]) if w != "" { if exclude { q.Where("MessageID NOT LIKE ?", "%"+escPercentChar(w)+"%") } else { q.Where("MessageID LIKE ?", "%"+escPercentChar(w)+"%") } } } else if strings.HasPrefix(lw, "tag:") { w = cleanString(w[4:]) if w != "" { if exclude { q.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w) } else { q.Where(`m.ID IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w) } } } else if lw == "is:read" { if exclude { q.Where("Read = 0") } else { q.Where("Read = 1") } } else if lw == "is:unread" { if exclude { q.Where("Read = 1") } else { q.Where("Read = 0") } } else if lw == "is:tagged" { if exclude { q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`) } else { q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN ` + tenant("tags") + ` t ON mt.TagID = t.ID)`) } } else if lw == "has:inline" || lw == "has:inlines" { if exclude { q.Where("Inline = 0") } else { q.Where("Inline > 0") } } else if lw == "has:attachment" || lw == "has:attachments" { if exclude { q.Where("Attachments = 0") } else { q.Where("Attachments > 0") } } else if strings.HasPrefix(lw, "after:") { w = cleanString(w[6:]) if w != "" { t, err := dateparse.ParseIn(w, loc) if err != nil { logger.Log().Warnf("ignoring invalid after: date \"%s\"", w) } else { timestamp := t.UnixMilli() if exclude { q.Where(`m.Created <= ?`, timestamp) } else { q.Where(`m.Created >= ?`, timestamp) } } } } else if strings.HasPrefix(lw, "before:") { w = cleanString(w[7:]) if w != "" { t, err := dateparse.ParseIn(w, loc) if err != nil { logger.Log().Warnf("ignoring invalid before: date \"%s\"", w) } else { timestamp := t.UnixMilli() if exclude { q.Where(`m.Created >= ?`, timestamp) } else { q.Where(`m.Created <= ?`, timestamp) } } } } else if strings.HasPrefix(lw, "larger:") && sizeToBytes(cleanString(w[7:])) > 0 { w = cleanString(w[7:]) size := sizeToBytes(w) if exclude { q.Where("Size < ?", size) } else { q.Where("Size > ?", size) } } else if strings.HasPrefix(lw, "smaller:") && sizeToBytes(cleanString(w[8:])) > 0 { w = cleanString(w[8:]) size := sizeToBytes(w) if exclude { q.Where("Size > ?", size) } else { q.Where("Size < ?", size) } } else { // search text if exclude { q.Where("SearchText NOT LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%") } else { q.Where("SearchText LIKE ?", "%"+cleanString(escPercentChar(strings.ToLower(w)))+"%") } } } return q } // Simple function to return a size in bytes, eg 2kb, 4MB or 1.5m. // // K, k, Kb, KB, kB and kb are treated as Kilobytes. // M, m, Mb, MB and mb are treated as Megabytes. func sizeToBytes(v string) uint64 { v = strings.ToLower(v) re := regexp.MustCompile(`^(\d+)(\.\d+)?\s?([a-z]{1,2})?$`) m := re.FindAllStringSubmatch(v, -1) if len(m) == 0 { return 0 } val := fmt.Sprintf("%s%s", m[0][1], m[0][2]) unit := m[0][3] i, err := strconv.ParseFloat(strings.TrimSpace(val), 64) if err != nil { return 0 } if unit == "" { return uint64(i) } if unit == "k" || unit == "kb" { return uint64(i * 1024) } if unit == "m" || unit == "mb" { return uint64(i * 1024 * 1024) } return 0 } ================================================ FILE: internal/storage/search_test.go ================================================ package storage import ( "bytes" "fmt" "math/rand/v2" "testing" "github.com/axllent/mailpit/config" "github.com/jhillyerd/enmime/v2" ) func TestSearch(t *testing.T) { for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) setup(tenantID) if tenantID == "" { t.Log("Testing search") } else { t.Logf("Testing search (tenant %s)", tenantID) } for i := range testRuns { msg := enmime.Builder(). From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)). CC(fmt.Sprintf("CC %d", i), fmt.Sprintf("cc-%d@example.com", i)). CC(fmt.Sprintf("CC2 %d", i), fmt.Sprintf("cc2-%d@example.com", i)). Subject(fmt.Sprintf("Subject line %d end", i)). Text(fmt.Appendf(nil, "This is the email body %d .", i)). To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)). To(fmt.Sprintf("To2 %d", i), fmt.Sprintf("to2-%d@example.com", i)). ReplyTo(fmt.Sprintf("Reply To %d", i), fmt.Sprintf("reply-to-%d@example.com", i)) env, err := msg.Build() if err != nil { t.Log("error ", err) t.Fail() } buf := new(bytes.Buffer) if err := env.Encode(buf); err != nil { t.Log("error ", err) t.Fail() } bufBytes := buf.Bytes() if _, err := Store(&bufBytes, nil); err != nil { t.Log("error ", err) t.Fail() } } for i := 1; i < 51; i++ { // search a random something that will return a single result uniqueSearches := []string{ fmt.Sprintf("from-%d@example.com", i), fmt.Sprintf("from:from-%d@example.com", i), fmt.Sprintf("to-%d@example.com", i), fmt.Sprintf("to:to-%d@example.com", i), fmt.Sprintf("to2-%d@example.com", i), fmt.Sprintf("to:to2-%d@example.com", i), fmt.Sprintf("cc-%d@example.com", i), fmt.Sprintf("cc:cc-%d@example.com", i), fmt.Sprintf("cc2-%d@example.com", i), fmt.Sprintf("cc:cc2-%d@example.com", i), fmt.Sprintf("reply-to-%d@example.com", i), fmt.Sprintf("reply-to:\"reply-to-%d@example.com\"", i), fmt.Sprintf("\"Subject line %d end\"", i), fmt.Sprintf("subject:\"Subject line %d end\"", i), fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i), } searchIdx := rand.IntN(len(uniqueSearches)) search := uniqueSearches[searchIdx] summaries, _, err := Search(search, "", 0, 0, 100) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, len(summaries), 1, "search result expected") assertEqual(t, summaries[0].From.Name, fmt.Sprintf("From %d", i), "\"From\" name does not match") assertEqual(t, summaries[0].From.Address, fmt.Sprintf("from-%d@example.com", i), "\"From\" address does not match") assertEqual(t, summaries[0].To[0].Name, fmt.Sprintf("To %d", i), "\"To\" name does not match") assertEqual(t, summaries[0].To[0].Address, fmt.Sprintf("to-%d@example.com", i), "\"To\" address does not match") assertEqual(t, summaries[0].Subject, fmt.Sprintf("Subject line %d end", i), "\"Subject\" does not match") } // search something that will return 200 results summaries, _, err := Search("This is the email body", "", 0, 0, testRuns) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, len(summaries), testRuns, "search results expected") Close() } } func TestSearchDelete100(t *testing.T) { for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) setup(tenantID) if tenantID == "" { t.Log("Testing search delete of 100 messages") } else { t.Logf("Testing search delete of 100 messages (tenant %s)", tenantID) } for range 100 { if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } } _, total, err := Search("from:sender@example.com", "", 0, 0, 100) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, total, 100, "100 search results expected") if err := DeleteSearch("from:sender@example.com", ""); err != nil { t.Log("error ", err) t.Fail() } _, total, err = Search("from:sender@example.com", "", 0, 0, 100) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, total, 0, "0 search results expected") Close() } } func TestSearchDelete1100(t *testing.T) { setup("") defer Close() t.Log("Testing search delete of 1100 messages") for range 1100 { if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } } _, total, err := Search("from:sender@example.com", "", 0, 0, 100) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, total, 1100, "100 search results expected") if err := DeleteSearch("from:sender@example.com", ""); err != nil { t.Log("error ", err) t.Fail() } _, total, err = Search("from:sender@example.com", "", 0, 0, 100) if err != nil { t.Log("error ", err) t.Fail() } assertEqual(t, total, 0, "0 search results expected") } func TestEscPercentChar(t *testing.T) { tests := map[string]string{} tests["this is a test"] = "this is a test" tests["this is% a test"] = "this is%% a test" tests["this is%% a test"] = "this is%%%% a test" tests["this is%%% a test"] = "this is%%%%%% a test" tests["%this is% a test"] = "%%this is%% a test" tests["Ä"] = "Ä" tests["Ä%"] = "Ä%%" for search, expected := range tests { res := escPercentChar(search) assertEqual(t, res, expected, "no match") } } func TestSizeToBytes(t *testing.T) { tests := map[string]uint64{} tests["1m"] = 1048576 tests["1mb"] = 1048576 tests["1 M"] = 1048576 tests["1 MB"] = 1048576 tests["1k"] = 1024 tests["1kb"] = 1024 tests["1 K"] = 1024 tests["1 kB"] = 1024 tests["1.5M"] = 1572864 tests["1234567890"] = 1234567890 tests["invalid"] = 0 tests["1.2.3"] = 0 tests["1.2.3M"] = 0 for search, expected := range tests { res := sizeToBytes(search) assertEqual(t, res, expected, "size does not match") } } ================================================ FILE: internal/storage/settings.go ================================================ package storage import ( "context" "database/sql" "github.com/axllent/mailpit/internal/logger" "github.com/leporo/sqlf" ) // SettingGet returns a setting string value, blank is it does not exist func SettingGet(k string) string { var result sql.NullString err := sqlf.From(tenant("settings")). Select("Value").To(&result). Where("Key = ?", k). Limit(1). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return "" } return result.String } // SettingPut sets a setting string value, inserting if new func SettingPut(k, v string) error { _, err := db.Exec(`INSERT INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) } return err } // The total deleted message size as an int64 value func getDeletedSize() uint64 { var result sql.NullFloat64 // use float64 for rqlite compatibility err := sqlf.From(tenant("settings")). Select("Value").To(&result). Where("Key = ?", "DeletedSize"). Limit(1). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return 0 } return uint64(result.Float64) } // The total raw non-compressed messages size in bytes of all messages in the database func totalMessagesSize() uint64 { var result sql.NullFloat64 err := sqlf.From(tenant("mailbox")). Select("SUM(Size)").To(&result). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return 0 } return uint64(result.Float64) } // AddDeletedSize will add the value to the DeletedSize setting func addDeletedSize(v uint64) { if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } } ================================================ FILE: internal/storage/structs.go ================================================ package storage import ( "net/mail" "time" ) // Message data excluding physical attachments // // swagger:model Message type Message struct { // Database ID ID string // Message ID MessageID string // From address From *mail.Address // To addresses To []*mail.Address // Cc addresses Cc []*mail.Address // Bcc addresses Bcc []*mail.Address // ReplyTo addresses ReplyTo []*mail.Address // Return-Path ReturnPath string // Message subject Subject string // List-Unsubscribe header information ListUnsubscribe ListUnsubscribe // Message RFC3339Nano date & time (if set), else date & time received // ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds) Date time.Time // Message tags Tags []string // Username used for authentication (if provided) with the SMTP or Send API Username string // Message body text Text string // Message body HTML HTML string // Message size in bytes Size uint64 // Inline message attachments Inline []Attachment // Message attachments Attachments []Attachment } // Attachment struct for inline images and attachments // // swagger:model Attachment type Attachment struct { // Attachment part ID PartID string // File name FileName string // Content type ContentType string // Content ID ContentID string // Size in bytes Size uint64 // File checksums Checksums struct { // MD5 checksum hash of file MD5 string // SHA1 checksum hash of file SHA1 string // SHA256 checksum hash of file SHA256 string } } // MessageSummary struct for frontend messages // // swagger:model MessageSummary type MessageSummary struct { // Database ID ID string // Message ID MessageID string // Read status Read bool // From address From *mail.Address // To address To []*mail.Address // Cc addresses Cc []*mail.Address // Bcc addresses Bcc []*mail.Address // Reply-To address ReplyTo []*mail.Address // Email subject Subject string // Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds) Created time.Time // Username used for authentication (if provided) with the SMTP or Send API Username string // Message tags Tags []string // Message size in bytes (total) Size uint64 // Whether the message has any attachments Attachments int // Message snippet includes up to 250 characters Snippet string } // MailboxStats struct for quick mailbox total/read lookups type MailboxStats struct { Total uint64 Unread uint64 Tags []string } // Metadata struct for storing message metadata type Metadata struct { From *mail.Address `json:"From,omitempty"` To []*mail.Address `json:"To,omitempty"` Cc []*mail.Address `json:"Cc,omitempty"` Bcc []*mail.Address `json:"Bcc,omitempty"` ReplyTo []*mail.Address `json:"ReplyTo,omitempty"` Username string `json:"Username,omitempty"` } // ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers // including validation of the link structure type ListUnsubscribe struct { // List-Unsubscribe header value Header string // Detected links, maximum one email and one HTTP(S) link Links []string // Validation errors (if any) Errors string // List-Unsubscribe-Post value (if set) HeaderPost string } ================================================ FILE: internal/storage/tagfilters.go ================================================ package storage import ( "context" "database/sql" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/leporo/sqlf" ) // TagFilter struct type TagFilter struct { // Match is the user-defined match Match string // SQL represents the SQL equivalent of Match SQL *sqlf.Stmt // Tags to add on match Tags []string } var tagFilters = []TagFilter{} // LoadTagFilters loads tag filters from the config and pre-generates the SQL query func LoadTagFilters() { tagFilters = []TagFilter{} for _, t := range config.TagFilters { match := strings.TrimSpace(t.Match) if match == "" { logger.Log().Warnf("[tags] ignoring tag item with missing 'match'") continue } if len(t.Tags) == 0 { logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array") continue } validTags := []string{} for _, tag := range t.Tags { tagName := tools.CleanTag(tag) if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 { logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName) continue } validTags = append(validTags, tagName) } if len(validTags) == 0 { continue } tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")}) } } // TagFilterMatches returns a slice of matching tags from a message func tagFilterMatches(id string) []string { tags := []string{} if len(tagFilters) == 0 { return tags } for _, f := range tagFilters { var matchID string q := f.SQL.Clone().Where("ID = ?", id) if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) { var ignore sql.NullString if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) return tags } if matchID == id { tags = append(tags, f.Tags...) } } return tags } ================================================ FILE: internal/storage/tags.go ================================================ package storage import ( "bytes" "context" "database/sql" "fmt" "regexp" "sort" "strings" "sync" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/websockets" "github.com/leporo/sqlf" ) var ( addressPlusRe = regexp.MustCompile(`(?U)^(.*){1,}\+(.*)@`) addTagMutex sync.RWMutex ) // SetMessageTags will set the tags for a given database ID, removing any not in the array func SetMessageTags(id string, tags []string) ([]string, error) { applyTags := []string{} for _, t := range tags { t = tools.CleanTag(t) if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) { applyTags = append(applyTags, t) } } tagNames := []string{} currentTags := getMessageTags(id) origTagCount := len(currentTags) for _, t := range applyTags { if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) { continue } name, err := addMessageTag(id, t) if err != nil { return []string{}, err } tagNames = append(tagNames, name) } if origTagCount > 0 { currentTags = getMessageTags(id) for _, t := range currentTags { if !tools.InArray(t, applyTags) { if err := deleteMessageTag(id, t); err != nil { return []string{}, err } } } } d := struct { ID string Tags []string }{ID: id, Tags: applyTags} websockets.Broadcast("update", d) return tagNames, nil } // AddMessageTag adds a tag to a message func addMessageTag(id, name string) (string, error) { // prevent two identical tags being added at the same time addTagMutex.Lock() var tagID int var foundName sql.NullString q := sqlf.From(tenant("tags")). Select("ID").To(&tagID). Select("Name").To(&foundName). Where("Name = ?", name) // if tag exists - add tag to message if err := q.QueryRowAndClose(context.TODO(), db); err == nil { addTagMutex.Unlock() // check message does not already have this tag var exists int if err := sqlf.From(tenant("message_tags")). Select("COUNT(ID)").To(&exists). Where("ID = ?", id). Where("TagID = ?", tagID). QueryRowAndClose(context.Background(), db); err != nil { return "", err } if exists > 0 { // already exists return foundName.String, nil } logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id) _, err := sqlf.InsertInto(tenant("message_tags")). Set("ID", id). Set("TagID", tagID). ExecAndClose(context.TODO(), db) return foundName.String, err } // new tag, add to the database if _, err := sqlf.InsertInto(tenant("tags")). Set("Name", name). ExecAndClose(context.TODO(), db); err != nil { addTagMutex.Unlock() return name, err } addTagMutex.Unlock() // add tag to the message return addMessageTag(id, name) } // DeleteMessageTag deletes a tag from a message func deleteMessageTag(id, name string) error { if _, err := sqlf.DeleteFrom(tenant("message_tags")). Where(tenant("message_tags.ID")+" = ?", id). Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN `+tenant("tags")+` ON TagID=`+tenant("tags.ID")+` WHERE Name = ?)`, name). ExecAndClose(context.TODO(), db); err != nil { return err } return pruneUnusedTags() } // GetAllTags returns all used tags func GetAllTags() []string { var tags = []string{} var name string if err := sqlf. Select(`DISTINCT Name`). From(tenant("tags")).To(&name). OrderBy("Name"). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags = append(tags, name) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } return tags } // GetAllTagsCount returns all used tags with their total messages func GetAllTagsCount() map[string]int64 { var tags = make(map[string]int64) var name string var total float64 // use float64 for rqlite compatibility if err := sqlf. Select(`Name`).To(&name). Select(`COUNT(`+tenant("message_tags.TagID")+`) as total`).To(&total). From(tenant("tags")). LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")). GroupBy(tenant("message_tags.TagID")). OrderBy("Name"). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags[name] = int64(total) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) } return tags } // RenameTag renames a tag func RenameTag(from, to string) error { to = tools.CleanTag(to) if to == "" || !config.ValidTagRegexp.MatchString(to) { return fmt.Errorf("invalid tag name: %s", to) } if from == to { return nil // ignore } var id, existsID int q := sqlf.From(tenant("tags")). Select(`ID`).To(&id). Where(`Name = ?`, from). Limit(1) err := q.QueryRowAndClose(context.Background(), db) if err != nil { return fmt.Errorf("tag not found: %s", from) } // check if another tag by this name already exists q = sqlf.From(tenant("tags")). Select("ID").To(&existsID). Where(`Name = ?`, to). Where(`ID != ?`, id). Limit(1) err = q.QueryRowAndClose(context.Background(), db) if err == nil || existsID != 0 { return fmt.Errorf("tag already exists: %s", to) } q = sqlf.Update(tenant("tags")). Set("Name", to). Where("ID = ?", id) _, err = q.ExecAndClose(context.Background(), db) return err } // DeleteTag deleted a tag and removed all references to the tag func DeleteTag(tag string) error { var id int q := sqlf.From(tenant("tags")). Select(`ID`).To(&id). Where(`Name = ?`, tag). Limit(1) err := q.QueryRowAndClose(context.Background(), db) if err != nil { return fmt.Errorf("tag not found: %s", tag) } // delete all references q = sqlf.DeleteFrom(tenant("message_tags")). Where(`TagID = ?`, id) _, err = q.ExecAndClose(context.Background(), db) if err != nil { return fmt.Errorf("error deleting tag references: %s", err.Error()) } // delete tag q = sqlf.DeleteFrom(tenant("tags")). Where(`ID = ?`, id) _, err = q.ExecAndClose(context.Background(), db) if err != nil { return fmt.Errorf("error deleting tag: %s", err.Error()) } return nil } // PruneUnusedTags will delete all unused tags from the database func pruneUnusedTags() error { q := sqlf.From(tenant("tags")). Select(tenant("tags.ID")+", "+tenant("tags.Name")+", COUNT("+tenant("message_tags.ID")+") as COUNT"). LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")). GroupBy(tenant("tags.ID")) toDel := []int{} if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { var n string var id int var c int if err := row.Scan(&id, &n, &c); err != nil { logger.Log().Errorf("[tags] %s", err.Error()) return } if c == 0 { logger.Log().Debugf("[tags] deleting unused tag \"%s\"", n) toDel = append(toDel, id) } }); err != nil { return err } if len(toDel) > 0 { for _, id := range toDel { if _, err := sqlf.DeleteFrom(tenant("tags")). Where("ID = ?", id). ExecAndClose(context.TODO(), db); err != nil { return err } } } return nil } // Find tags set via --tags in raw message, useful for matching all headers etc. // This function is largely superseded by the database searching, however this // includes literally everything and is kept for backwards compatibility. // Returns a comma-separated string. func findTagsInRawMessage(message *[]byte) []string { tags := []string{} if len(tagFilters) == 0 { return tags } str := bytes.ToLower(*message) for _, t := range tagFilters { if bytes.Contains(str, []byte(t.Match)) { tags = append(tags, t.Tags...) } } return tags } // Returns tags found in email plus addresses (eg: test+tagname@example.com) func (d Metadata) tagsFromPlusAddresses() []string { tags := []string{} for _, c := range d.To { matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1) if len(matches) == 1 { tags = append(tags, strings.Split(matches[0][2], "+")...) } } for _, c := range d.Cc { matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1) if len(matches) == 1 { tags = append(tags, strings.Split(matches[0][2], "+")...) } } for _, c := range d.Bcc { matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1) if len(matches) == 1 { tags = append(tags, strings.Split(matches[0][2], "+")...) } } matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1) if len(matches) == 1 { tags = append(tags, strings.Split(matches[0][2], "+")...) } return tools.SetTagCasing(tags) } // Get message tags from the database for a given database ID // Used when parsing a raw email. func getMessageTags(id string) []string { tags := []string{} var name string if err := sqlf. Select(`Name`).To(&name). From(tenant("Tags")). LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")). Where(tenant("message_tags.ID")+` = ?`, id). OrderBy("Name"). QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags = append(tags, name) }); err != nil { logger.Log().Errorf("[tags] %s", err.Error()) return tags } return tags } // SortedUniqueTags will return a unique slice of normalised tags func sortedUniqueTags(s []string) []string { tags := []string{} added := make(map[string]bool) if len(s) == 0 { return tags } for _, p := range s { w := tools.CleanTag(p) if w == "" { continue } lc := strings.ToLower(w) if _, exists := added[lc]; exists { continue } if config.ValidTagRegexp.MatchString(w) { added[lc] = true tags = append(tags, w) } else { logger.Log().Debugf("[tags] ignoring invalid tag: %s", w) } } sort.Strings(tags) return tags } ================================================ FILE: internal/storage/tags_test.go ================================================ package storage import ( "context" "fmt" "slices" "strings" "testing" "github.com/axllent/mailpit/config" "github.com/leporo/sqlf" ) func TestTags(t *testing.T) { for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { tenantID = config.DBTenantID(tenantID) setup(tenantID) if tenantID == "" { t.Log("Testing tags") } else { t.Logf("Testing tags (tenant %s)", tenantID) } ids := []string{} for range 10 { id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() } ids = append(ids, id) } for i := range 10 { if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil { t.Log("error ", err) t.Fail() } } for i := range 10 { message, err := GetMessage(ids[i]) if err != nil { t.Log("error ", err) t.Fail() } if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) { t.Fatal("Message tags do not match") } } if err := DeleteAllMessages(); err != nil { t.Log("error ", err) t.Fail() } // test 20 tags id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() } newTags := []string{} for i := range 20 { // pad number with 0 to ensure they are returned alphabetically newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i)) } if _, err := SetMessageTags(id, newTags); err != nil { t.Log("error ", err) t.Fail() } returnedTags := getMessageTags(id) assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match") // remove first tag if err := deleteMessageTag(id, newTags[0]); err != nil { t.Log("error ", err) t.Fail() } returnedTags = getMessageTags(id) assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1") // remove all tags if err := deleteAllMessageTags(id); err != nil { t.Log("error ", err) t.Fail() } returnedTags = getMessageTags(id) assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty") // apply the same tag twice if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil { t.Log("error ", err) t.Fail() } returnedTags = getMessageTags(id) assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated") if err := deleteAllMessageTags(id); err != nil { t.Log("error ", err) t.Fail() } // apply tag with invalid characters if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil { t.Log("error ", err) t.Fail() } returnedTags = getMessageTags(id) assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected") if err := deleteAllMessageTags(id); err != nil { t.Log("error ", err) t.Fail() } // Check deleted message tags also prune the tags database allTags := GetAllTags() assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected") if err := DeleteAllMessages(); err != nil { t.Log("error ", err) t.Fail() } // test 20 tags id, err = Store(&testTagEmail, nil) if err != nil { t.Log("error ", err) t.Fail() } returnedTags = getMessageTags(id) assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly") if err := deleteAllMessageTags(id); err != nil { t.Log("error ", err) t.Fail() } Close() } } func TestUsernameAutoTagging(t *testing.T) { setup("") defer Close() username := "testuser" t.Run("Auto-tagging enabled", func(t *testing.T) { config.TagsUsername = true id, err := Store(&testTextEmail, &username) if err != nil { t.Fatalf("Store failed: %v", err) } msg, err := GetMessage(id) if err != nil { t.Fatalf("GetMessage failed: %v", err) } found := slices.Contains(msg.Tags, username) if !found { t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags) } }) t.Run("Auto-tagging disabled", func(t *testing.T) { config.TagsUsername = false id, err := Store(&testTextEmail, &username) if err != nil { t.Fatalf("Store failed: %v", err) } msg, err := GetMessage(id) if err != nil { t.Fatalf("GetMessage failed: %v", err) } for _, tag := range msg.Tags { if tag == username { t.Errorf("Did not expect username '%s' in tags when disabled, got %v", username, msg.Tags) } } }) } // DeleteAllMessageTags deleted all tags from a message func deleteAllMessageTags(id string) error { if _, err := sqlf.DeleteFrom(tenant("message_tags")). Where(tenant("message_tags.ID")+" = ?", id). ExecAndClose(context.TODO(), db); err != nil { return err } return pruneUnusedTags() } ================================================ FILE: internal/storage/testdata/inline-attachment.eml ================================================ From: sender@example.com To: recipient@example.com Subject: Test inline image proper MIME-Version: 1.0 Content-Type: multipart/related; boundary="boundary123" --boundary123 Content-Type: text/html; charset=utf-8 Test --boundary123 Content-Type: image/png; name="test1.png" Content-Disposition: inline; filename="test1.png" Content-ID: Content-Transfer-Encoding: base64 iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== --boundary123-- ================================================ FILE: internal/storage/testdata/mime-attachment.eml ================================================ Delivered-To: recipient2@example.com Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs; Tue, 26 Jul 2022 20:42:36 -0700 (PDT) X-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881; Tue, 26 Jul 2022 20:42:35 -0700 (PDT) ARC-Seal: i=1; a=rsa-sha256; t=1658893355; cv=none; d=google.com; s=arc-20160816; b=WkNqsJS6Q7RhLY79RZAXgq+Moe0ZcMpGfkZMPq+v1YvG9yAao+QVeY+lN0vjM27H39 0QcXaTd4me7k0f96We657eNyjXSVaJyvvEYMA/Eu/bM51DrzsqywIfMq/O/xsA64mHph o8LBjV3YjjfNY1uN3q/eLLd5ZLEiHulQSyKJwXxPs7FXaCiihK1iys4U/wEcVubANo0K 3DLhQ2NYrFOjN4jEyw8Agv3PjmLwgAFFisjt49Zm0N6sIDjgWLncXPQ0dA7MjKKE6pjQ terzh43sjNeI6O+WQJ+aZ6nDxLzhgc+tk0sa290o4u7mjH8/qRx8/krqSPPlgGjLbdyo Utlg== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; h=subject:from:to:content-language:user-agent:mime-version:date :message-id:dkim-signature; bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=; b=hnIfinlV6u631zlofA336KWFWzAQrScmCiXIxlBoBrZfgy0FsVJ07tRXSzqqeofkHU k9pEKVJtD0FfkKzVdrAjetlBAbWCmbQf2u0AzaWqYVLk1rGSQj+UdpuIzMSuB5tX6sX5 XgGvkQC6cYoSd/pRGcxmrA6+jnW531pGvaQzxyv3rpcnYrOT+LBgxaaFVn3fEeUC+AWs ZQHfciTV9hRCrmu2JWo47Z8RDr9SV3TLU/Mbf8G/p+PiaxhfxYarcTEoiV8+PuD9g6Et tm1PAqdGq7NAWezv943ueamREZHWiD9+h1gSOro/BmdpWmigEhKFovxRlbAzwsZtW7xo uSfA== ARC-Authentication-Results: i=1; mx.google.com; dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa; spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com; dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com Return-Path: Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41]) by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35 for (Google Transport Security); Tue, 26 Jul 2022 20:42:35 -0700 (PDT) Received-SPF: pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41; Authentication-Results: mx.google.com; dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa; spf=pass (google.com: domain of sender2@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender2@example.com; dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=message-id:date:mime-version:user-agent:content-language:to:from :subject; bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=; b=mywi6bMa68lM9RENvBG2mjVlMvGhyZCrh3z9gE57KY0ZK0RLLPFxzAVOXtJpaTGQ0M C4W33O+7h5cvgFkLQJHc5YCemxEjCE5Dz5/uH4iSBYowkvn7Gu4TudNZtkNw8TGxH/Lf lKJiaqtdnm8YdLWCzG1M/scBbbjZxDrTLddshu/Q1ireNliVwl9WdN25zXQLxsEqHFXc 5rVjyruB7cnshL8m14LYi+m5iN3H+o42oGzVce3+wQ31s+Bo/LBezb0qD8TRfTjnhp8u 77RU61IOSMbuwQWNQCywxCnoZolZpR9qRgzd5rg73dGpXHIyNfBsYyb5vr28+fp93Ayo LXyw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20210112; h=x-gm-message-state:message-id:date:mime-version:user-agent :content-language:to:from:subject; bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=; b=BvDmv5WC7f4WSVvuypzr9WNT7AUCQeEexvjmGur1rfkZcmqr62punbNEvcyk6T5Iy7 8XstlNbijtU9zT3qm5LBTEw1e7q8VACWVVHUbI5uE4NhqXbY6vfN4bxrDzRO/P+Ntr90 BwH1dYSBLpYOmFGX6GlrOCg0X1MZgzGI92YakpQitGBjhKnWvvQ4NlX7Ivk6W6W2aHt5 xkIVmZNdC13evcdFUOrQxcfFAkIe3kSR8eGVt++yoHlCt/fFv/QQjf5L9fEbteuA8h2V pnfH4fN5z+GF3rpeSl1VebfW8NtPy/iHAze6dlodAVM0jtaom8MtHSXfquCea/2giq0o YXQQ== X-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77 pdFYYiNICxragxqhNqXvw7/elR8u6B8= X-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ== X-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675; Tue, 26 Jul 2022 20:42:34 -0700 (PDT) Return-Path: Received: from [192.168.1.2] ([8.8.8.8]) by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32 for (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128); Tue, 26 Jul 2022 20:42:33 -0700 (PDT) Content-Type: multipart/mixed; boundary="------------ae0qIOkrNQLQHe1YyfTsUXrk" Message-ID: <33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com> Date: Wed, 27 Jul 2022 15:42:29 +1200 MIME-Version: 1.0 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Thunderbird/91.11.0 Content-Language: en-NZ To: "Recipient Ross" From: Sender Smith Subject: inline + attachment This is a multi-part message in MIME format. --------------ae0qIOkrNQLQHe1YyfTsUXrk Content-Type: multipart/alternative; boundary="------------GGc8vauWscgVN0JHIav4AOeV" --------------GGc8vauWscgVN0JHIav4AOeV Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit Message with inline image and attachment: --------------GGc8vauWscgVN0JHIav4AOeV Content-Type: multipart/related; boundary="------------z0ttbxz8BplvjsfeE7Zogcgs" --------------z0ttbxz8BplvjsfeE7Zogcgs Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 7bit

Message with inline image and attachment:




--------------z0ttbxz8BplvjsfeE7Zogcgs Content-Type: image/jpeg; name="inline-image.jpg" Content-Disposition: inline; filename="inline-image.jpg" Content-Id: Content-Transfer-Encoding: base64 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 0sFHRw0lPGGQwxtjjaBsa0DACuAF9DV7DVk1eQFTqH8XHs/SO5XAb4lY1DteUnkGwL0vjPFj Pm3bqPbi87yJw4/XcrfC+Y2KphfMFfWPnFMjkXwhVhGXZwM4GVXobfVVtSynpoXOe44GzZ51 EzEe5WiJmfSx1QvJZg5V3PTSxSGNzTrDfs3Kv+TahsLJZWtjZIMs1jglRyiPunjKLLSvrG5O CqxZk4AyhYWnaMFW2hSezmXloIcpS0Wi5Xep63tlFPVS7MtjYTjJwM8yy+Pgm0qdqh7aKN5c W6rqgZyP/o3c6yv5GPH6vbTSmHJf3WGIaNvqHXOGmhYZH1MjYxGN7nE4GPaVpXqo9HTatM6e 6tiMYuERZM0twRNHhpz49Ut+4rvPgv4PaLRS3y1F2ioq25cbxrJeL1jCG7g0kZB5cjxcy566 uqxw1Oi5v9NHxcbaxk+7lP2bh7S9p86+a+Qy0zZeVI/v8vb8THfHj43cZoiLgdIiIgIiICIi AiIgIiICIiApmx9yO9IegKGUzY+5HekPQElMLS/9/bh61J8xVir6/wDf24etSfMVYogREQFu DqZYg65XubG1sMTR7XOP9Fp9bn6l8Zqr9/wQdMiielq9t2Bq9BqqBq9Bqo0UZe1icfEo7Ck6 sfYHxlWOovpvhqRGGbfmXg/KW3liPxCiWr4WqqWr5gr13mPVFG507WsIDjs2nAWe26ens+sa Uyumdsa+XaADyhYHGSx4c04IUrR3ioiJ4/7Zp5Hci5fIxzd1ePkinaaZUurrlJRVTMPJyXtb gEHnyFK1lk69g60kbF9m37EjYdg51jE93455nBMc+tkareTxqS0bvk8taIqyXWjAOq7dhcmT HeI5V9adVMtJnU+9vkFjpaG4QmSmJY9vauLgdoG0q1nssMt0oouLw+plDDh2wA7M43//AIvF 2rZH3cvMuvGDqtYNwblZ3adFdIK6C33iAw00gLXU7XjacneR5ktktjiLWntNa1vutY6ZroPo rQaK6PTOjw2qnAEshB2kZxs9qib5cmiqjMQPGMxgDnWfV8Dm0bWzuBYR25zgZxtWFXaCjpZO O1WuO8DmXjRkm9ptb3MvSmsVrEV6XFxvTnxRySs+2LcapcRyeJae6pCCW8cC2kHXDO1pqfjm jlGq5p6QPuWxamcSRcYY8uz2uN6xThcoHVHApppI53F8RZppNU8uBuUWiIrJuZl+eKIi5Wgi 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 P//Z --------------z0ttbxz8BplvjsfeE7Zogcgs-- --------------GGc8vauWscgVN0JHIav4AOeV-- --------------ae0qIOkrNQLQHe1YyfTsUXrk Content-Type: application/pdf; name="Sample PDF.pdf" Content-Disposition: attachment; filename="Sample PDF.pdf" Content-Transfer-Encoding: base64 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 lVBVBLNnVSqzPTCrEi6uzFcungCV+TDTgBkGVEyAcpSzvAhYCMqOQSmKUBqCkgKYjhqcbsC0 Y3BRPUzFyVQDpoRgMmpqsgGT8NCkqeA0oNiAiQYUIUCRAQ6U1VEP9s0wIQSFBhSMn6QUGDAe ocdPgnwDqB3GGZCHYHkG5BZDTghk3JTRA5yAq2CADee2OSDZgRggDUuhrXdIl/xv+CEfNwNn /Cn5H+T5xf0KZW5kc3RyZWFtCmVuZG9iagoKNiAwIG9iago3MjA5CmVuZG9iagoKNyAwIG9i ago8PC9UeXBlL0ZvbnREZXNjcmlwdG9yL0ZvbnROYW1lL0JBQUFBQStEZWphVnVTYW5zCi9G bGFncyA0Ci9Gb250QkJveFstMTAyMCAtNDYyIDE3OTIgMTIzMl0vSXRhbGljQW5nbGUgMAov QXNjZW50IDkyOAovRGVzY2VudCAtMjM1Ci9DYXBIZWlnaHQgMTIzMgovU3RlbVYgODAKL0Zv bnRGaWxlMiA1IDAgUgo+PgplbmRvYmoKCjggMCBvYmoKPDwvTGVuZ3RoIDI2NS9GaWx0ZXIv RmxhdGVEZWNvZGU+PgpzdHJlYW0KeJxdkE1uwyAQhfecgmW6iMCO7TSSZalyFMmLtFXdHgDD 2EGKAWG88O3LT9pKXYC+YeYN84a03blT0pF3q3kPDo9SCQuLXi0HPMAkFcpyLCR3jyjefGYG Ea/tt8XB3KlR1zUiHz63OLvh3YvQAzwh8mYFWKkmvPtqex/3qzF3mEE5TFHTYAGj73Nl5pXN QKJq3wmflm7be8lfwedmAOcxztIoXAtYDONgmZoA1ZQ2uL5cGgRK/MudkmIY+Y1ZX5n5SkrL Q+M5j1xlgQ+Jz4GLyEcauEzvbeAqcRn4mPrEmufIRRH4lLiKszx+DVOFtf24xXy11juNu40W gzmp4Hf9Rpugiucbox+AAgplbmRzdHJlYW0KZW5kb2JqCgo5IDAgb2JqCjw8L1R5cGUvRm9u dC9TdWJ0eXBlL1RydWVUeXBlL0Jhc2VGb250L0JBQUFBQStEZWphVnVTYW5zCi9GaXJzdENo YXIgMAovTGFzdENoYXIgOQovV2lkdGhzWzYwMCA2MzQgNjEyIDk3NCA2MzQgMjc3IDYxNSA2 MDMgNzcwIDU3NSBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+ CmVuZG9iagoKMTAgMCBvYmoKPDwvRjEgOSAwIFIKPj4KZW5kb2JqCgoxMSAwIG9iago8PC9G b250IDEwIDAgUgovUHJvY1NldFsvUERGL1RleHRdCj4+CmVuZG9iagoKMSAwIG9iago8PC9U eXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5 NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0Nv bnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl cyAxMSAwIFIKL01lZGlhQm94WyAwIDAgNTk1IDg0MiBdCi9LaWRzWyAxIDAgUiBdCi9Db3Vu dCAxPj4KZW5kb2JqCgoxMiAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgNCAwIFIKL09w ZW5BY3Rpb25bMSAwIFIgL1hZWiBudWxsIG51bGwgMF0KL0xhbmcoZW4tTlopCj4+CmVuZG9i agoKMTMgMCBvYmoKPDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgov UHJvZHVjZXI8RkVGRjAwNEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMw MDY1MDAyMDAwMzUwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwNjE2MTM0NDU4KzEy JzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDgz NTggMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjE5IDAwMDAwIG4gCjAw MDAwMDg1MDEgMDAwMDAgbiAKMDAwMDAwMDIzOSAwMDAwMCBuIAowMDAwMDA3NTMzIDAwMDAw IG4gCjAwMDAwMDc1NTQgMDAwMDAgbiAKMDAwMDAwNzc0NyAwMDAwMCBuIAowMDAwMDA4MDgx IDAwMDAwIG4gCjAwMDAwMDgyNzEgMDAwMDAgbiAKMDAwMDAwODMwMyAwMDAwMCBuIAowMDAw MDA4NjAwIDAwMDAwIG4gCjAwMDAwMDg2OTcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDE0 L1Jvb3QgMTIgMCBSCi9JbmZvIDEzIDAgUgovSUQgWyA8Nzg2RkVDMTY2OUIxOURDMTJBNEU2 ODQzN0YxQjIzRTE+Cjw3ODZGRUMxNjY5QjE5REMxMkE0RTY4NDM3RjFCMjNFMT4gXQovRG9j Q2hlY2tzdW0gLzkzRjFCMUZBQjVENzc2Q0JFNDc2MzA1QzdENUVCRUUxCj4+CnN0YXJ0eHJl Zgo4ODcyCiUlRU9GCg== --------------ae0qIOkrNQLQHe1YyfTsUXrk-- ================================================ FILE: internal/storage/testdata/mixed-attachment.eml ================================================ From: sender@example.com To: recipient@example.com Subject: Test mixed attachments MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="boundary111" --boundary111 Content-Type: text/html; charset=utf-8 Inline

Document attached

--boundary111 Content-Type: image/png; name="inline.png" Content-Disposition: inline; filename="inline.png" Content-ID: Content-Transfer-Encoding: base64 iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg== --boundary111 Content-Type: application/pdf; name="document.pdf" Content-Disposition: attachment; filename="document.pdf" Content-Transfer-Encoding: base64 JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo= --boundary111-- ================================================ FILE: internal/storage/testdata/plain-text.eml ================================================ Delivered-To: recipient@example.com Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp146390qvs; Tue, 26 Jul 2022 20:45:20 -0700 (PDT) X-Received: by 2002:a17:90a:1943:b0:1ef:8146:f32f with SMTP id 3-20020a17090a194300b001ef8146f32fmr2327371pjh.112.1658893508159; Tue, 26 Jul 2022 20:45:08 -0700 (PDT) ARC-Seal: i=1; a=rsa-sha256; t=1658893507; cv=none; d=google.com; s=arc-20160816; b=KrXcumoy4Oldq3Ny6ZLUfED4+/+4ndNbrM3uw1COEhqCVWWv7lLfFeNHTyxJQJLBK3 tVgmPBX2XRmX+531CFRNquUDrqhsvc4kgIq0ExWPz99wG2vgsKWQ2x89AIfQ8sEYMwxY HOwErTH6XQuJ45YE+5Lt4pjMP+7NqnJ1NTRQyc7FB/c1Wt1JdTWscgaJGqUMnIFSbCPG xi0xpJnrIkh4giARIhabCRmVoo1g8BfzYrmy8uHtbIcDDuCJ8tN2lMLscwfw3u8hZWm6 e1nAx4iDYyShdMZPPoUVoMHDf9P39DKwhdfb/xP/cQ6ulv7ECzVSp5DM8aLpfjw6SU9G JYJA== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816; h=content-disposition:mime-version:message-id:subject:to:from:date :dkim-signature; bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=; b=TGK9vlNQRpyHvcpQonLjrFuLubL2mo9vT15CPwtC6ltsrYccKUozKiyb+id79dPatM y2unMpJqJFB4rZnASRm20Ck9dFRulM8bowO4l9BWKAUti9+u7bmLYbOPQCgDmJRA88ij YTkSKE8TuFMZQMJTkyZZTwE3F/Vrv84fAekWzGlwFoV3D6r6t1D5EUYUoR4xCVZdpMo1 Ic0bEqgmRXl44uEqyVNpIC0w86Hzz84zl2V+nca+gxfObMzbJheDkOwVKkNNmr0ja936 QZK+aO9s9VQGtqmjWtWhc1OWO50Bc5vE/krLFvZM6+vbMBEuDE5rkfHdf5mSD9Ix4xWl 6/Rg== ARC-Authentication-Results: i=1; mx.google.com; dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP; spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com; dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com Return-Path: Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41]) by mx.google.com with SMTPS id t3-20020a17090a2f8300b001f25e258dfasor335081pjd.34.2022.07.26.20.45.07 for (Google Transport Security); Tue, 26 Jul 2022 20:45:07 -0700 (PDT) Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41; Authentication-Results: mx.google.com; dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP; spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com; dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=date:from:to:subject:message-id:mime-version:content-disposition; bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=; b=fpxRepVPdRgZF9VI4rCzO4n1l9+OHrm254/c1PaNcNnC1+0Rr78o1ASLvDKoQY4INc gRN1kJIk+ozQumJSfQPEIe+rHbJxe+wzjbYhEfUwBUnFHZykqvYWl6Xmjwg61IhxwwWk b3Gp/ODHkdQrm5QqIFACEn1fQmqkk4XBlcKMYEU/NOswGDOFULfbrhDcBWmR/gp2kHmT DkqRA9UJ1Cc6GO9lG+McRi8uLNaTymuLwzBydVV0bZOQTLxHQcQBTfUFrp/fwjHc9V19 l9uQcn5rOOsh3vR37NGpv8WPi7BORLRFGjMVD0DZ7CtJwTDHz4EVvdLijt6YbUV9ecp1 df3Q== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20210112; h=x-gm-message-state:date:from:to:subject:message-id:mime-version :content-disposition; bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=; b=Z8ndxERf1NU67swjZ7cSjkSTTaa2YzhtrRyJkg0vnRxi87af7ECZNT+Zaxuxmxmqvb 5T3IN2ymjPu1Y52EqRdZQpnzS/E5OjHbA6AYSn5qneNXNDxqJwp5qVSXuyB265QOo/9M bGp4fqfi8Qe5pmgkzyTqyrigWFOzcl23sCGXqvnrD8+0e+/n1dqo2tYk4v2KpSoAUxF0 SNwHocpTDBDxOMEulUkQpqNlyZsgqNGdRhZmUN+2tQnpCQULd4B7+pydyWBCp9o8J1W4 0IqmhJiNT8pB8MVzyUsWNG+WX9GBh8PK6XndOjmp2WvYh0LcUKeEYQ6zBsIdDFNEkMD1 dU9w== X-Gm-Message-State: AJIora+ZXWhiNwKn6ik6LuIUHc1hskP3Nneo2J0m0wSC9wwGXI1RPi1a Ml5Ex/pAryQwTi7MXqbUQkCIrEe5kU0= X-Google-Smtp-Source: AGRyM1v7CWOR6/X4d18Wv11XTnkfT25QfmsqBowwGsebQlPqhR1ogD3bo1sZRs/OSAHP7AjywIebfw== X-Received: by 2002:a17:90a:5e0b:b0:1f0:5565:ee6e with SMTP id w11-20020a17090a5e0b00b001f05565ee6emr2290528pjf.128.1658893506447; Tue, 26 Jul 2022 20:45:06 -0700 (PDT) Return-Path: Received: from localhost.localhost ([8.8.8.8]) by smtp.gmail.com with ESMTPSA id s7-20020a170902ea0700b0016a3f9e4865sm12488166plg.148.2022.07.26.20.45.04 for (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Tue, 26 Jul 2022 20:45:06 -0700 (PDT) Date: Wed, 27 Jul 2022 15:44:41 +1200 From: Sender Smith To: Recipient Ross Subject: Plain text message Message-ID: <20220727034441.7za34h6ljuzfpmj6@localhost.localhost> MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia, fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat, mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at posuere libero. Fusce a gravida nibh. Nulla ac odio ex. Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus. Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet. Pellentesque enim nibh, varius at ante id, consequat posuere ante. Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel ipsum. Cras condimentum posuere vulputate. Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales. Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt. Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus. ================================================ FILE: internal/storage/testdata/regular-attachment.eml ================================================ From: sender@example.com To: recipient@example.com Subject: Test regular attachment MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="boundary789" --boundary789 Content-Type: text/html; charset=utf-8

Message with regular attachment

--boundary789 Content-Type: application/pdf; name="document.pdf" Content-Disposition: attachment; filename="document.pdf" Content-Transfer-Encoding: base64 JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQo= --boundary789-- ================================================ FILE: internal/storage/testdata/tags.eml ================================================ Date: Wed, 27 Jul 2022 15:44:41 +1200 From: Sender Smith To: Recipient Ross Cc: Recipient Ross Bcc: Subject: Plain text message X-Tags: X-tag1, X-tag2 Message-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost> MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Disposition: inline Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia, fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat, mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at posuere libero. Fusce a gravida nibh. Nulla ac odio ex. Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus. Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet. Pellentesque enim nibh, varius at ante id, consequat posuere ante. Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel ipsum. Cras condimentum posuere vulputate. Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales. Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt. Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus. ================================================ FILE: internal/storage/utils.go ================================================ package storage import ( "net/mail" "os" "regexp" "strings" "sync" "github.com/axllent/mailpit/internal/html2text" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/tools" "github.com/jhillyerd/enmime/v2" ) var ( // for stats to prevent import cycle mu sync.RWMutex // StatsDeleted for counting the number of messages deleted StatsDeleted uint64 ) // AddTempFile adds a file to the slice of files to delete on exit func AddTempFile(s string) { temporaryFiles = append(temporaryFiles, s) } // DeleteTempFiles will delete files added via AddTempFiles func deleteTempFiles() { for _, f := range temporaryFiles { if err := os.Remove(f); err == nil { logger.Log().Debugf("removed temporary file: %s", f) } } } // Return a header field as a []*mail.Address, or "null" is not found/empty func addressToSlice(env *enmime.Envelope, key string) []*mail.Address { data, err := env.AddressList(key) if err != nil || data == nil { return []*mail.Address{} } return data } // Generate the search text based on some header fields (to, from, subject etc) // and either the stripped HTML body (if exists) or text body func createSearchText(env *enmime.Envelope) string { var b strings.Builder b.WriteString(env.GetHeader("From") + " ") b.WriteString(env.GetHeader("Subject") + " ") b.WriteString(env.GetHeader("To") + " ") b.WriteString(env.GetHeader("Cc") + " ") b.WriteString(env.GetHeader("Bcc") + " ") b.WriteString(env.GetHeader("Reply-To") + " ") b.WriteString(env.GetHeader("Return-Path") + " ") h := html2text.Strip(env.HTML, true) if h != "" { b.WriteString(h + " ") } else { b.WriteString(env.Text + " ") } // add attachment filenames for _, a := range env.Attachments { b.WriteString(a.FileName + " ") } d := cleanString(b.String()) return d } // CleanString removes unwanted characters from stored search text and search queries func cleanString(str string) string { // replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184 str = strings.ReplaceAll(str, string('\uFEFF'), " ") // remove/replace new lines re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`) str = re.ReplaceAllString(str, " ") // remove duplicate whitespace and trim return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " ")) } // LogMessagesDeleted logs the number of messages deleted func logMessagesDeleted(n int) { mu.Lock() StatsDeleted = StatsDeleted + tools.SafeUint64(n) mu.Unlock() } // IsFile returns whether a path is a file func isFile(path string) bool { info, err := os.Stat(path) if os.IsNotExist(err) || !info.Mode().IsRegular() { return false } return true } // Convert `%` to `%%` for SQL searches func escPercentChar(s string) string { return strings.ReplaceAll(s, "%", "%%") } ================================================ FILE: internal/tools/argsparser.go ================================================ package tools import "strings" // ArgsParser will split a string by new words and quotes phrases func ArgsParser(s string) []string { args := []string{} sb := &strings.Builder{} quoted := false for _, r := range s { if r == '"' { quoted = !quoted sb.WriteRune(r) // keep '"' otherwise comment this line } else if !quoted && r == ' ' { v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", "")) if v != "" { args = append(args, v) } sb.Reset() } else { sb.WriteRune(r) } } if sb.Len() > 0 { v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", "")) if v != "" { args = append(args, v) } } return args } ================================================ FILE: internal/tools/fs.go ================================================ package tools import ( "os" "path/filepath" ) // IsFile returns whether a file exists and is readable func IsFile(path string) bool { f, err := os.Open(filepath.Clean(path)) defer func() { _ = f.Close() }() return err == nil } // IsDir returns whether a path is a directory func IsDir(path string) bool { info, err := os.Stat(path) if err != nil || os.IsNotExist(err) || !info.IsDir() { return false } return true } ================================================ FILE: internal/tools/headers.go ================================================ // Package tools provides various methods for various things package tools import ( "bufio" "bytes" "net/mail" "regexp" "strings" "github.com/axllent/mailpit/internal/logger" ) // RemoveMessageHeaders scans a message for headers, if found them removes them. // It will only remove a single instance of any given message header. func RemoveMessageHeaders(msg []byte, headers []string) ([]byte, error) { reader := bytes.NewReader(msg) m, err := mail.ReadMessage(reader) if err != nil { return nil, err } reBlank := regexp.MustCompile(`^\s+`) for _, hdr := range headers { // case-insensitive reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(hdr+":")) // header := []byte(hdr + ":") if m.Header.Get(hdr) != "" { scanner := bufio.NewScanner(bytes.NewReader(msg)) found := false hdr := []byte("") for scanner.Scan() { line := scanner.Bytes() if !found && reHdr.Match(line) { // add the first line starting with
: 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
break } } if len(hdr) > 0 { logger.Log().Debugf("[relay] removed %s header", hdr) msg = bytes.Replace(msg, hdr, []byte(""), 1) } } } return msg, nil } // SetMessageHeader scans a message for a header and updates its value if found. // It does not consider multiple instances of the same header. // If not found it will add the header to the beginning of the message. func SetMessageHeader(msg []byte, header, value string) ([]byte, error) { reader := bytes.NewReader(msg) m, err := mail.ReadMessage(reader) if err != nil { return nil, err } if m.Header.Get(header) != "" { reBlank := regexp.MustCompile(`^\s+`) reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(header+":")) scanner := bufio.NewScanner(bytes.NewReader(msg)) found := false hdr := []byte("") for scanner.Scan() { line := scanner.Bytes() if !found && reHdr.Match(line) { // add the first line starting with
: 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
break } } return bytes.Replace(msg, hdr, []byte(header+": "+value+"\r\n"), 1), nil } // no header, so add one to beginning return append([]byte(header+": "+value+"\r\n"), msg...), nil } // OverrideFromHeader scans a message for the From header and replaces it with a different email address. func OverrideFromHeader(msg []byte, address string) ([]byte, error) { reader := bytes.NewReader(msg) m, err := mail.ReadMessage(reader) if err != nil { return nil, err } if m.Header.Get("From") != "" { reBlank := regexp.MustCompile(`^\s+`) reHdr := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta("From:")) scanner := bufio.NewScanner(bytes.NewReader(msg)) found := false hdr := []byte("") for scanner.Scan() { line := scanner.Bytes() if !found && reHdr.Match(line) { // add the first line starting with
: 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
break } } if len(hdr) > 0 { originalFrom := strings.TrimRight(string(hdr[5:]), "\r\n") from, err := mail.ParseAddress(originalFrom) if err != nil { // error parsing the from address, so just replace the whole line msg = bytes.Replace(msg, hdr, []byte("From: "+address+"\r\n"), 1) } else { originalFrom = from.Address // replace the from email, but keep the original name from.Address = address msg = bytes.Replace(msg, hdr, []byte("From: "+from.String()+"\r\n"), 1) } // insert the original From header as X-Original-From msg = append([]byte("X-Original-From: "+originalFrom+"\r\n"), msg...) logger.Log().Debugf("[relay] Replaced From email address with %s", address) } } else { // no From header, so add one msg = append([]byte("From: "+address+"\r\n"), msg...) logger.Log().Debugf("[relay] Added From email: %s", address) } return msg, nil } ================================================ FILE: internal/tools/html.go ================================================ 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) } // SetHTMLAttributeVal sets an attribute on a node. func SetHTMLAttributeVal(n *html.Node, key, val string) { for i := range n.Attr { a := &n.Attr[i] if a.Key == key { a.Val = val return } } n.Attr = append(n.Attr, html.Attribute{ Key: key, Val: val, }) } // WalkHTML traverses the entire HTML tree and calls fn on each node. func WalkHTML(n *html.Node, fn func(*html.Node)) { if n == nil { return } fn(n) // Each node has a pointer to its first child and next sibling. To traverse // all children of a node, we need to start from its first child and then // traverse the next sibling until nil. for c := n.FirstChild; c != nil; c = c.NextSibling { WalkHTML(c, fn) } } ================================================ FILE: internal/tools/listunsubscribeparser.go ================================================ package tools import ( "fmt" "net/url" "regexp" "strings" ) // ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return // a slide of addresses (mail & URLs) func ListUnsubscribeParser(v string) ([]string, error) { var results = []string{} var re = regexp.MustCompile(`(?mU)<(.*)>`) var reJoins = regexp.MustCompile(`(?imUs)>(.*)<`) var reValidJoinChars = regexp.MustCompile(`(?imUs)^(\s+)?,(\s+)?$`) var reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`) var reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`) var reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`) var reSpaces = regexp.MustCompile(`\s`) var reComments = regexp.MustCompile(`(?mUs)\(.*\)`) var hasMailTo bool var hasHTTP bool v = strings.TrimSpace(v) comments := reComments.FindAllStringSubmatch(v, -1) for _, c := range comments { // strip comments v = strings.ReplaceAll(v, c[0], "") v = strings.TrimSpace(v) } if !re.MatchString(v) { return results, fmt.Errorf("\"%s\" no valid unsubscribe links found", v) } errors := []string{} if !reWrapper.MatchString(v) { return results, fmt.Errorf("\"%s\" should be enclosed in <>", v) } matches := re.FindAllStringSubmatch(v, -1) if len(matches) > 2 { errors = append(errors, fmt.Sprintf("\"%s\" should include a maximum of one email and one HTTP link", v)) } else { splits := reJoins.FindAllStringSubmatch(v, -1) for _, g := range splits { if !reValidJoinChars.MatchString(g[1]) { return results, fmt.Errorf("\"%s\" <> should be split with a comma and optional spaces", v) } } for _, m := range matches { r := m[1] if reSpaces.MatchString(r) { errors = append(errors, fmt.Sprintf("\"%s\" should not contain spaces", r)) continue } if reMailTo.MatchString(r) { if hasMailTo { errors = append(errors, fmt.Sprintf("\"%s\" should only contain one mailto:", r)) continue } hasMailTo = true } else if reHTTP.MatchString(r) { if hasHTTP { errors = append(errors, fmt.Sprintf("\"%s\" should only contain one HTTP link", r)) continue } hasHTTP = true } else { errors = append(errors, fmt.Sprintf("\"%s\" should start with either http(s):// or mailto:", r)) continue } _, err := url.ParseRequestURI(r) if err != nil { errors = append(errors, err.Error()) continue } results = append(results, r) } } var err error if len(errors) > 0 { err = fmt.Errorf("%s", strings.Join(errors, ", ")) } return results, err } ================================================ FILE: internal/tools/net.go ================================================ package tools import ( "net" "net/url" ) // IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast). // IsLoopback — 127.0.0.0/8, ::1 // IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 // IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254) // IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16 // IsUnspecified — 0.0.0.0, :: // IsMulticast — 224.0.0.0/4, ff00::/8 func IsInternalIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() } // IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname. func IsValidLinkURL(str string) bool { u, err := url.Parse(str) return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" } ================================================ FILE: internal/tools/snippets.go ================================================ package tools import ( "regexp" "strings" "github.com/axllent/mailpit/internal/html2text" ) // CreateSnippet returns a message snippet. It will use the HTML version (if it exists) // otherwise the text version. func CreateSnippet(text, html string) string { text = strings.TrimSpace(text) html = strings.TrimSpace(html) limit := 200 spaceRe := regexp.MustCompile(`\s+`) if text == "" && html == "" { return "" } if html != "" { data := html2text.Strip(html, false) if len(data) <= limit { return data } return truncate(data, limit) + "..." } if text != "" { // replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184 text = strings.ReplaceAll(text, string('\uFEFF'), " ") text = strings.TrimSpace(spaceRe.ReplaceAllString(text, " ")) if len(text) <= limit { return text } return truncate(text, limit) + "..." } return "" } // Truncate a string allowing for multi-byte encoding. // Shamelessly borrowed from Tailscale. // See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go func truncate(s string, n int) string { if n >= len(s) { return s } // Back up until we find the beginning of a UTF-8 encoding. for n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte n-- } // If we're at the beginning of a multi-byte encoding, back up one more to // skip it. It's possible the value was already complete, but it's simpler // if we only have to check in one direction. // // Otherwise, we have a single-byte code (0x00... or 0x01...). if n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding n-- } return s[:n] } ================================================ FILE: internal/tools/tags.go ================================================ package tools import ( "regexp" "strings" "golang.org/x/text/cases" "golang.org/x/text/language" ) var ( // Invalid tag characters regex tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.@]`) // Regex to catch multiple spaces multiSpaceRe = regexp.MustCompile(`(\s+)`) // TagsTitleCase enforces TitleCase on all tags TagsTitleCase bool ) // CleanTag returns a clean tag, trimming whitespace and replacing invalid characters. // If the tag is longer than 100 characters, it is truncated. func CleanTag(s string) string { t := strings.TrimSpace( multiSpaceRe.ReplaceAllString( tagsInvalidChars.ReplaceAllString(s, " "), " ", ), ) if len(t) > 100 { return t[:100] } return t } // SetTagCasing returns the slice of tags, title-casing if set func SetTagCasing(s []string) []string { if !TagsTitleCase { return s } titleTags := []string{} c := cases.Title(language.Und, cases.NoLower) for _, t := range s { titleTags = append(titleTags, c.String(t)) } return titleTags } ================================================ FILE: internal/tools/tools_test.go ================================================ package tools import ( "reflect" "testing" ) func TestArgsParser(t *testing.T) { tests := map[string][]string{} tests["this is a test"] = []string{"this", "is", "a", "test"} tests["\"this is\" a test"] = []string{"this is", "a", "test"} tests["!\"this is\" a test"] = []string{"!this is", "a", "test"} tests["subject:this is a test"] = []string{"subject:this", "is", "a", "test"} tests["subject:\"this is\" a test"] = []string{"subject:this is", "a", "test"} tests["subject:\"this is\" \"a test\""] = []string{"subject:this is", "a test"} tests["subject:\"this 'is\" \"a test\""] = []string{"subject:this 'is", "a test"} tests["subject:\"this 'is a test"] = []string{"subject:this 'is a test"} tests["\"this is a test\"=\"this is a test\""] = []string{"this is a test=this is a test"} for search, expected := range tests { res := ArgsParser(search) if !reflect.DeepEqual(res, expected) { t.Log("Args parser error:", res, "!=", expected) t.Fail() } } } func TestCleanTag(t *testing.T) { tests := map[string]string{} tests["this is a test"] = "this is a test" tests["thiS IS a Test"] = "thiS IS a Test" tests["thiS IS a Test :-)"] = "thiS IS a Test -" tests[" thiS 99 IS a Test :-)"] = "thiS 99 IS a Test -" tests["this_is-a test "] = "this_is-a test" tests["this_is-a&^%%(*)@ test"] = "this_is-a @ test" tests["this is a long tag title with more than 100 characters, which should get automatically truncated to 100 characters"] = "this is a long tag title with more than 100 characters which should get automatically truncated to 1" for search, expected := range tests { res := CleanTag(search) if res != expected { t.Log("CleanTags error:", res, "!=", expected) t.Fail() } } } func TestSnippets(t *testing.T) { tests := map[string]string{} tests["this is a test"] = "this is a test" tests["thiS IS a Test"] = "thiS IS a Test" tests["thiS IS a Test :-)"] = "thiS IS a Test :-)" tests["

This is a test.

"] = "This is a test." tests["this_is-a test "] = "this_is-a test" tests["this_is-a&^%%(*)@ test"] = "this_is-a&^%%(*)@ test" tests["

Heading

Paragraph

"] = "Heading Paragraph" tests[`

Heading

Paragraph

`] = "Heading Paragraph" tests[`

Heading

linked text

`] = "Heading linked text" // broken html tests[`

Heading

linked text.`] = "Heading linked text." // truncation to 200 chars + ... tests["abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789"] = "abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmnopqrstuvwxyx0123456789 abcdefghijklmno..." for str, expected := range tests { res := CreateSnippet(str, str) if res != expected { t.Log("CreateSnippet error:", res, "!=", expected) t.Fail() } } } func TestListUnsubscribeParser(t *testing.T) { tests := map[string]bool{} // should pass tests[""] = true tests[""] = true tests[""] = true tests[", "] = true tests[", "] = true tests[", "] = true tests[" , "] = true tests[" ,"] = true tests[","] = true tests[` , `] = true tests[""] = true tests["(Use this command to get off the list) "] = true tests[" (Use this command to get off the list)"] = true tests["(Use this command to get off the list) , (Click this link to unsubscribe) "] = true // should fail tests["mailto:unsubscribe@example.com"] = false // no <> tests[""] = false // :: tests["https://example.com/"] = false // no <> tests["mailto:unsubscribe@example.com, "] = false // no <> tests[""] = false // capitals tests[", "] = false // two emails tests[", "] = false // two links tests[", , "] = false // two links tests[", "] = false // no mailto || http(s) tests[", "] = false // space tests[""] = false // space tests[""] = false // http:/// for search, expected := range tests { _, err := ListUnsubscribeParser(search) hasError := err != nil if expected == hasError { if err != nil { t.Logf("ListUnsubscribeParser: %v", err) } else { t.Logf("ListUnsubscribeParser: \"%s\" expected: %v", search, expected) } t.Fail() } } } ================================================ FILE: internal/tools/unixsocket.go ================================================ package tools import ( "fmt" "io/fs" "net" "os" "path" "regexp" "strconv" ) // UnixSocket returns a path and a FileMode if the address is in // the format of unix:: func UnixSocket(address string) (string, fs.FileMode, bool) { re := regexp.MustCompile(`^unix:(.*):(\d\d\d\d?)$`) var f fs.FileMode if !re.MatchString(address) { return "", f, false } m := re.FindAllStringSubmatch(address, 1) modeVal, err := strconv.ParseUint(m[0][2], 8, 32) if err != nil { return "", f, false } return path.Clean(m[0][1]), fs.FileMode(modeVal), true } // PrepareSocket returns an error if an active socket file already exists func PrepareSocket(address string) error { address = path.Clean(address) if _, err := os.Stat(address); os.IsNotExist(err) { // does not exist, OK return nil } if _, err := net.Dial("unix", address); err == nil { // socket is listening return fmt.Errorf("socket already in use: %s", address) } return os.Remove(address) } ================================================ FILE: internal/tools/utils.go ================================================ package tools import ( "fmt" "regexp" "strings" ) // Plural returns a singular or plural of a word together with the total func Plural(total int, singular, plural string) string { if total == 1 { return fmt.Sprintf("%d %s", total, singular) } return fmt.Sprintf("%d %s", total, plural) } // InArray tests if a string is within an array. It is not case sensitive. func InArray(k string, arr []string) bool { for _, v := range arr { if strings.EqualFold(v, k) { return true } } return false } // Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces func Normalize(s string) string { nlRe := regexp.MustCompile(`\r?\r`) re := regexp.MustCompile(`\s+`) s = nlRe.ReplaceAllString(s, " ") s = re.ReplaceAllString(s, " ") return strings.TrimSpace(s) } // SafeUint64 converts an int or int64 to uint64, ensuring it does not exceed the maximum value for uint64. func SafeUint64(i any) uint64 { switch v := i.(type) { case int: if v < 0 { return 0 } return uint64(v) case int64: if v < 0 { return 0 } return uint64(v) default: // only accepts int or int64 return 0 } } ================================================ FILE: main.go ================================================ // Package main is the entrypoint package main import ( "os" "path/filepath" "strings" "github.com/axllent/mailpit/cmd" sendmail "github.com/axllent/mailpit/sendmail/cmd" ) func main() { // if the command executable contains "send" in the name (eg: sendmail), then run the sendmail command if strings.Contains(strings.ToLower(filepath.Base(os.Args[0])), "send") { sendmail.Run() } else { // else run mailpit cmd.Execute() } } ================================================ FILE: package.json ================================================ { "name": "mailpit", "version": "0.0.0", "type": "module", "private": true, "scripts": { "build": "MINIFY=true node esbuild.config.mjs", "watch": "WATCH=true node esbuild.config.mjs", "package": "MINIFY=true node esbuild.config.mjs", "update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json", "lint": "eslint --max-warnings 0 && prettier -c .", "lint-fix": "eslint --fix && prettier --write ." }, "dependencies": { "axios": "^1.13.5", "bootstrap": "^5.2.0", "bootstrap-icons": "^1.9.1", "bootstrap5-tags": "^1.6.1", "color-hash": "^2.0.2", "dayjs": "^1.11.10", "dompurify": "^3.1.6", "highlight.js": "^11.11.1", "ical.js": "^2.0.1", "mitt": "^3.0.1", "modern-screenshot": "^4.4.30", "rapidoc": "^9.3.4", "timezones-list": "^3.0.3", "vue": "^3.2.13", "vue-css-donut-chart": "^2.0.0", "vue-router": "^4.2.4" }, "devDependencies": { "@eslint/compat": "^2.0.2", "@eslint/js": "^10.0.1", "@popperjs/core": "^2.11.5", "@types/bootstrap": "^5.2.7", "@types/tinycon": "^0.6.3", "@vue/compiler-sfc": "^3.2.37", "esbuild": "^0.27.2", "esbuild-plugin-vue-next": "^0.1.4", "esbuild-sass-plugin": "^3.0.0", "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.5", "eslint-plugin-vue": "^10.2.0", "globals": "^17.3.0", "prettier": "^3.5.3" }, "prettier": { "tabWidth": 4, "useTabs": true, "printWidth": 120 } } ================================================ FILE: sendmail/cmd/cmd.go ================================================ // 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" "net/mail" "os" "os/user" "path" "regexp" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/mneis/go-telnet" flag "github.com/spf13/pflag" ) var ( // SMTPAddr address SMTPAddr = "localhost:1025" // FromAddr email address FromAddr string // UseB - used to set from `-bs` UseB bool // UseS - used to set from `-bs` UseS bool ) func init() { // ensure only valid characters are used, ie: windows re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`) host, err := os.Hostname() if err != nil { host = "localhost" } else { host = re.ReplaceAllString(host, "-") } username := "nobody" user, err := user.Current() if err == nil && user != nil && len(user.Username) > 0 { username = re.ReplaceAllString(user.Username, "-") } if FromAddr == "" { FromAddr = username + "@" + host } } // Run the Mailpit sendmail replacement. func Run() { var recipients []string // defaults from env vars if provided if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 { SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR") } if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 { 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") flag.StringP("from-name", "F", "", "Ignored") flag.StringP("bits", "B", "", "Ignored") flag.StringP("errors", "e", "", "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, "") flag.Parse() // allow recipients to be passed as an argument recipients = flag.Args() // if run via `mailpit sendmail ...` then remove `sendmail` from "recipients" if len(recipients) > 0 && recipients[0] == "sendmail" { recipients = recipients[1:] } if showHelp { flag.Usage() os.Exit(0) } // ensure -bs is set if UseB && !UseS || !UseB && UseS { fmt.Printf("error: use -bs") os.Exit(1) } socketAddr, isSocket := socketAddress(SMTPAddr) // handles `sendmail -bs` // telnet directly to SMTP if UseB && UseS { var caller = telnet.StandardCaller switch isSocket { case true: if err := telnet.DialToAndCallUnix(socketAddr, caller); err != nil { fmt.Println(err) os.Exit(1) } default: if err := telnet.DialToAndCall(SMTPAddr, caller); err != nil { fmt.Println(err) os.Exit(1) } } return } body, err := io.ReadAll(os.Stdin) if err != nil { fmt.Fprintln(os.Stderr, "error reading stdin") os.Exit(11) } msg, err := mail.ReadMessage(bytes.NewReader(body)) if err != nil { fmt.Fprintf(os.Stderr, "error parsing message body: %si\n", 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) } } } from, err := mail.ParseAddress(FromAddr) if err != nil { fmt.Fprintln(os.Stderr, "invalid from address") os.Exit(11) } if err := Send(SMTPAddr, from.Address, addresses, body); err != nil { fmt.Fprintln(os.Stderr, "error sending mail") logger.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 -F string Ignored -B string Ignored -e string Ignored `, config.Version, strings.Join(args, " "), FromAddr) } // SocketAddress returns a path and a FileMode if the address is in // the format of unix: func socketAddress(address string) (string, bool) { re := regexp.MustCompile(`^unix:(.*)$`) if !re.MatchString(address) { return "", false } m := re.FindAllStringSubmatch(address, 1) return path.Clean(m[0][1]), true } ================================================ FILE: sendmail/cmd/smtp.go ================================================ // Package cmd is a wrapper library to send mail package cmd import ( "crypto/tls" "errors" "fmt" "net" "net/mail" "net/smtp" "os" "strings" "github.com/axllent/mailpit/internal/logger" ) // Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets. // Unix sockets must be set as unix:/path/to/socket // It does not support authentication. func Send(addr string, from string, to []string, msg []byte) error { socketPath, isSocket := socketAddress(addr) fromAddress, err := mail.ParseAddress(from) if err != nil { return fmt.Errorf("invalid from address: %s", from) } if len(to) == 0 { return fmt.Errorf("no To addresses specified") } if !isSocket { return sendMail(addr, nil, fromAddress.Address, to, msg) } conn, err := net.Dial("unix", socketPath) if err != nil { return fmt.Errorf("error connecting to %s", addr) } client, err := smtp.NewClient(conn, "") if err != nil { return err } // Set the sender if err := client.Mail(fromAddress.Address); err != nil { fmt.Fprintln(os.Stderr, "error sending mail") logger.Log().Fatal(err) } // Set the recipient for _, a := range to { if err := client.Rcpt(a); err != nil { return err } } wc, err := client.Data() if err != nil { return err } _, err = wc.Write(msg) if err != nil { return err } err = wc.Close() if err != nil { return err } return nil } func sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error { if err := validateLine(from); err != nil { return err } for _, recipient := range to { if err := validateLine(recipient); err != nil { return err } } c, err := smtp.Dial(addr) if err != nil { return err } defer func() { _ = c.Close() }() // Use the local hostname for EHLO/HELO as required by RFC 5321. // Fall back to "localhost" if the hostname cannot be determined. localHostname, err := os.Hostname() if err != nil { localHostname = "localhost" } if err = c.Hello(localHostname); err != nil { return err } if ok, _ := c.Extension("STARTTLS"); ok { config := &tls.Config{ServerName: addr, InsecureSkipVerify: true} // #nosec if err = c.StartTLS(config); err != nil { return err } } if a != nil { if ok, _ := c.Extension("AUTH"); !ok { return errors.New("smtp: server doesn't support AUTH") } if err = c.Auth(a); err != nil { return err } } if err = c.Mail(from); err != nil { return err } for _, addr := range to { if err = c.Rcpt(addr); err != nil { return err } } w, err := c.Data() if err != nil { return err } _, err = w.Write(msg) if err != nil { return err } err = w.Close() if err != nil { return err } return c.Quit() } // validateLine checks to see if a line has CR or LF as per RFC 5321. func validateLine(line string) error { if strings.ContainsAny(line, "\n\r") { return errors.New("smtp: A line must not contain CR or LF") } return nil } ================================================ FILE: sendmail/main.go ================================================ package main import "github.com/axllent/mailpit/sendmail/cmd" func main() { cmd.Run() } ================================================ FILE: server/apiv1/api.go ================================================ // Package apiv1 handles all the API responses package apiv1 import ( "encoding/json" "fmt" "net/http" "strconv" "github.com/araddon/dateparse" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" ) // FourOFour returns a basic 404 message func fourOFour(w http.ResponseWriter) { w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) w.WriteHeader(http.StatusNotFound) w.Header().Set("Content-Type", "text/plain") _, _ = fmt.Fprint(w, "404 page not found") } // HTTPError returns a basic error message (400 response) func httpError(w http.ResponseWriter, msg string) { w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) w.WriteHeader(http.StatusBadRequest) w.Header().Set("Content-Type", "text/plain") _, _ = fmt.Fprint(w, msg) } // httpJSONError returns a basic error message (400 response) in JSON format func httpJSONError(w http.ResponseWriter, msg string) { w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) w.WriteHeader(http.StatusBadRequest) e := struct{ Error string }{Error: msg} w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(e); err != nil { httpError(w, err.Error()) } } // Get the start and limit based on query params. Defaults to 0, 50 func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) { start = 0 limit = 50 beforeTS = 0 // timestamp 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 > -1 { limit = n } b := req.URL.Query().Get("before") if b != "" { t, err := dateparse.ParseLocal(b) if err != nil { logger.Log().Warnf("ignoring invalid before: date \"%s\"", b) } else { beforeTS = t.UnixMilli() } } return start, beforeTS, limit } // GetOptions returns a blank response func GetOptions(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte("")) } ================================================ FILE: server/apiv1/application.go ================================================ package apiv1 import ( "encoding/json" "fmt" "net/http" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/stats" ) // AppInfo returns some basic details about the running app including the latest release (unless disabled). func AppInfo(w http.ResponseWriter, _ *http.Request) { // swagger:route GET /api/v1/info application AppInformation // // # Get application information // // Returns basic runtime information, message totals and latest release version. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: AppInfoResponse // 400: ErrorResponse w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(stats.Load(true)); err != nil { httpError(w, err.Error()) } } // WebUIConfig returns configuration settings for the web UI. func WebUIConfig(w http.ResponseWriter, _ *http.Request) { // swagger:route GET /api/v1/webui application WebUIConfigurationResponse // // # 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 // 400: ErrorResponse conf := webUIConfigurationResponse{} conf.Body.Label = config.Label conf.Body.MessageRelay.Enabled = config.ReleaseEnabled if config.ReleaseEnabled { conf.Body.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port) conf.Body.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath conf.Body.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients conf.Body.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients conf.Body.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom conf.Body.MessageRelay.PreserveMessageIDs = config.SMTPRelayConfig.PreserveMessageIDs // DEPRECATED 2024/03/12 conf.Body.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients } conf.Body.SpamAssassin = config.EnableSpamAssassin != "" conf.Body.ChaosEnabled = chaos.Enabled conf.Body.DuplicatesIgnored = config.IgnoreDuplicateIDs conf.Body.HideDeleteAllButton = config.HideDeleteAllButton w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(conf.Body); err != nil { httpError(w, err.Error()) } } ================================================ FILE: server/apiv1/chaos.go ================================================ package apiv1 import ( "encoding/json" "net/http" "github.com/axllent/mailpit/internal/smtpd/chaos" ) // GetChaos returns the current Chaos triggers func GetChaos(w http.ResponseWriter, _ *http.Request) { // swagger:route GET /api/v1/chaos testing getChaos // // # Get Chaos triggers // // Returns the current Chaos triggers configuration. // This API route will return an error if Chaos is not enabled at runtime. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: ChaosResponse // 400: ErrorResponse if !chaos.Enabled { httpError(w, "Chaos is not enabled") return } conf := chaos.Config w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(conf); err != nil { httpError(w, err.Error()) } } // SetChaos sets the Chaos configuration. func SetChaos(w http.ResponseWriter, r *http.Request) { // swagger:route PUT /api/v1/chaos testing setChaosParams // // # Set Chaos triggers // // Set the Chaos triggers configuration and return the updated values. // This API route will return an error if Chaos is not enabled at runtime. // // If any triggers are omitted from the request, then those are reset to their // default values with a 0% probability (ie: disabled). // Setting a blank `{}` will reset all triggers to their default values. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: ChaosResponse // 400: ErrorResponse if !chaos.Enabled { httpError(w, "Chaos is not enabled") return } data := chaos.Triggers{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&data) if err != nil { httpError(w, err.Error()) return } if err := chaos.SetFromStruct(data); err != nil { httpError(w, err.Error()) return } conf := chaos.Config w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(conf); err != nil { httpError(w, err.Error()) } } ================================================ FILE: server/apiv1/message.go ================================================ package apiv1 import ( "bytes" "encoding/json" "fmt" "net/http" "net/mail" "net/url" "github.com/axllent/mailpit/internal/storage" "github.com/gorilla/mux" ) // GetMessage (method: GET) returns the Message as JSON func GetMessage(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID} message GetMessageParams // // # Get message summary // // Returns the summary of a message, marking the message as read. // // The ID can be set to `latest` to return the latest message. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: Message // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } msg, err := storage.GetMessage(id) if err != nil { fourOFour(w) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(msg); err != nil { httpError(w, err.Error()) } } // GetHeaders (method: GET) returns the message headers as JSON func GetHeaders(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/headers message GetHeadersParams // // # Get message headers // // Returns the message headers as an array. Note that header keys are returned alphabetically. // // The ID can be set to `latest` to return the latest message headers. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: MessageHeadersResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } data, err := storage.GetMessageRaw(id) if err != nil { fourOFour(w) return } reader := bytes.NewReader(data) m, err := mail.ReadMessage(reader) if err != nil { httpError(w, err.Error()) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(m.Header); err != nil { httpError(w, err.Error()) } } // DownloadAttachment (method: GET) returns the attachment data func DownloadAttachment(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/part/{PartID} message AttachmentParams // // # Get message attachment // // This will return the attachment part using the appropriate Content-Type. // // The ID can be set to `latest` to reference the latest message. // // Produces: // - application/* // - image/* // - text/* // // Schemes: http, https // // Responses: // 200: BinaryResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] partID := vars["partID"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } a, err := storage.GetAttachmentPart(id, partID) if err != nil { fourOFour(w) return } fileName := a.FileName if fileName == "" { fileName = a.ContentID } w.Header().Add("Content-Type", a.ContentType) w.Header().Set("Content-Disposition", "inline; filename=\""+url.PathEscape(fileName)+"\"") _, _ = w.Write(a.Content) } // DownloadRaw (method: GET) returns the full email source as plain text func DownloadRaw(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/raw message DownloadRawParams // // # Get message source // // Returns the full email source as plain text. // // The ID can be set to `latest` to return the latest message source. // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: TextResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] dl := r.FormValue("dl") if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } data, err := storage.GetMessageRaw(id) if err != nil { fourOFour(w) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") if dl == "1" { w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") } _, _ = w.Write(data) } ================================================ FILE: server/apiv1/messages.go ================================================ package apiv1 import ( "encoding/json" "net/http" "strings" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" ) // MessagesSummary is a summary of a list of messages type MessagesSummary struct { // Total number of messages in mailbox Total uint64 `json:"total"` // Total number of unread messages in mailbox Unread uint64 `json:"unread"` // Legacy - now undocumented in API specs but left for backwards compatibility. // Removed from API documentation 2023-07-12 // swagger:ignore Count uint64 `json:"count"` // Total number of messages matching current query MessagesCount uint64 `json:"messages_count"` // Total number of unread messages matching current query MessagesUnreadCount uint64 `json:"messages_unread"` // Pagination offset Start int `json:"start"` // All current tags Tags []string `json:"tags"` // Messages summary // in: body Messages []storage.MessageSummary `json:"messages"` } // GetMessages returns a paginated list of messages as JSON func GetMessages(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/messages messages GetMessagesParams // // # List messages // // Returns messages from the mailbox ordered from newest to oldest. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: MessagesSummaryResponse // 400: ErrorResponse start, beforeTS, limit := getStartLimit(r) messages, err := storage.List(start, beforeTS, limit) if err != nil { httpError(w, err.Error()) return } stats := storage.StatsGet() var res MessagesSummary res.Start = start res.Messages = messages res.Count = uint64(len(messages)) // legacy - now undocumented in API specs res.Total = stats.Total res.Unread = stats.Unread res.Tags = stats.Tags res.MessagesCount = stats.Total res.MessagesUnreadCount = stats.Unread w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(res); err != nil { httpError(w, err.Error()) } } // SetReadStatus (method: PUT) will update the status to Read/Unread for all provided IDs. func SetReadStatus(w http.ResponseWriter, r *http.Request) { // swagger:route PUT /api/v1/messages messages SetReadStatusParams // // # Set read status // // You can optionally provide an array of IDs or a search string. // If neither IDs nor search is provided then all mailbox messages are updated. // // Consumes: // - application/json // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse decoder := json.NewDecoder(r.Body) var data struct { Read bool IDs []string Search string } err := decoder.Decode(&data) if err != nil { httpError(w, err.Error()) return } ids := data.IDs search := data.Search if len(ids) > 0 && search != "" { httpError(w, "You may specify either IDs or a search query, not both") return } if search != "" { err := storage.SetSearchReadStatus(search, r.URL.Query().Get("tz"), data.Read) if err != nil { httpError(w, err.Error()) return } } else 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 { if err := storage.MarkRead(ids); err != nil { httpError(w, err.Error()) return } } else { if err := storage.MarkUnread(ids); err != nil { httpError(w, err.Error()) return } } } w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } // DeleteMessages (method: DELETE) deletes all messages matching IDS. func DeleteMessages(w http.ResponseWriter, r *http.Request) { // swagger:route DELETE /api/v1/messages messages DeleteMessagesParams // // # Delete messages // // Delete individual or all messages. If no IDs are provided then all messages are deleted. // // Consumes: // - application/json // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse decoder := json.NewDecoder(r.Body) var data struct { IDs []string } err := decoder.Decode(&data) if err != nil || len(data.IDs) == 0 { if err := storage.DeleteAllMessages(); err != nil { httpError(w, err.Error()) return } } else { if err := storage.DeleteMessages(data.IDs); err != nil { httpError(w, err.Error()) return } } w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } // Search returns the latest messages as JSON func Search(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/search messages SearchParams // // # Search messages // // Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending). // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: MessagesSummaryResponse // 400: ErrorResponse search := strings.TrimSpace(r.URL.Query().Get("query")) if search == "" { httpError(w, "Error: no search query") return } start, beforeTS, limit := getStartLimit(r) messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit) if err != nil { httpError(w, err.Error()) return } stats := storage.StatsGet() var res MessagesSummary res.Start = start res.Messages = messages res.Count = tools.SafeUint64(len(messages)) // legacy - now undocumented in API specs res.Total = stats.Total // total messages in mailbox res.MessagesCount = tools.SafeUint64(results) res.Unread = stats.Unread res.Tags = stats.Tags unread, err := storage.SearchUnreadCount(search, r.URL.Query().Get("tz"), beforeTS) if err != nil { httpError(w, err.Error()) return } res.MessagesUnreadCount = tools.SafeUint64(unread) w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(res); err != nil { httpError(w, err.Error()) } } // DeleteSearch will delete all messages matching a search func DeleteSearch(w http.ResponseWriter, r *http.Request) { // swagger:route DELETE /api/v1/search messages DeleteSearchParams // // # Delete messages by search // // Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/). // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse search := strings.TrimSpace(r.URL.Query().Get("query")) if search == "" { httpError(w, "Error: no search query") return } if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil { httpError(w, err.Error()) return } w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } ================================================ FILE: server/apiv1/other.go ================================================ package apiv1 import ( "bytes" "encoding/json" "fmt" "net/http" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/htmlcheck" "github.com/axllent/mailpit/internal/linkcheck" "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/storage" "github.com/gorilla/mux" "github.com/jhillyerd/enmime/v2" ) // HTMLCheck returns a summary of the HTML client support func HTMLCheck(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/html-check other HTMLCheckParams // // # HTML check // // Returns the summary of the message HTML checker. // // The ID can be set to `latest` to return the latest message. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: HTMLCheckResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { fourOFour(w) return } } raw, err := storage.GetMessageRaw(id) if err != nil { fourOFour(w) return } e := bytes.NewReader(raw) parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) msg, err := parser.ReadEnvelope(e) if err != nil { httpError(w, err.Error()) return } if msg.HTML == "" { httpError(w, "message does not contain HTML") return } checks, err := htmlcheck.RunTests(msg.HTML) if err != nil { httpError(w, err.Error()) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(checks); err != nil { httpError(w, err.Error()) } } // LinkCheck returns a summary of links in the email func LinkCheck(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/link-check other LinkCheckParams // // # Link check // // Returns the summary of the message Link checker. // // The ID can be set to `latest` to return the latest message. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: LinkCheckResponse // 400: ErrorResponse // 404: NotFoundResponse if config.DemoMode { httpError(w, "this functionality has been disabled for demonstration purposes") return } vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { fourOFour(w) return } } msg, err := storage.GetMessage(id) if err != nil { fourOFour(w) return } f := r.URL.Query().Get("follow") followRedirects := f == "true" || f == "1" summary, err := linkcheck.RunTests(msg, followRedirects) if err != nil { httpError(w, err.Error()) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(summary); err != nil { httpError(w, err.Error()) } } // SpamAssassinCheck returns a summary of SpamAssassin results (if enabled) func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/sa-check other SpamAssassinCheckParams // // # SpamAssassin check // // Returns the SpamAssassin summary (if enabled) of the message. // // The ID can be set to `latest` to return the latest message. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: SpamAssassinResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } msg, err := storage.GetMessageRaw(id) if err != nil { fourOFour(w) return } summary, err := spamassassin.Check(msg) if err != nil { httpError(w, err.Error()) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(summary); err != nil { httpError(w, err.Error()) } } ================================================ FILE: server/apiv1/release.go ================================================ package apiv1 import ( "bytes" "encoding/json" "net/http" "net/mail" "strings" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/smtpd" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/gorilla/mux" "github.com/lithammer/shortuuid/v4" ) // ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. func ReleaseMessage(w http.ResponseWriter, r *http.Request) { // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessageParams // // # Release message // // Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured. // // The ID can be set to `latest` to reference the latest message. // // Consumes: // - application/json // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse // 404: NotFoundResponse if config.DemoMode { httpError(w, "this functionality has been disabled for demonstration purposes") return } vars := mux.Vars(r) id := vars["id"] msg, err := storage.GetMessageRaw(id) if err != nil { fourOFour(w) return } decoder := json.NewDecoder(r.Body) var data struct { To []string } if err := decoder.Decode(&data); err != nil { httpError(w, err.Error()) return } blocked := []string{} notAllowed := []string{} for _, to := range data.To { address, err := mail.ParseAddress(to) if err != nil { httpError(w, "Invalid email address: "+to) return } if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) { notAllowed = append(notAllowed, to) continue } if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) { blocked = append(blocked, to) continue } } if len(notAllowed) > 0 { addr := tools.Plural(len(notAllowed), "Address", "Addresses") httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", ")) return } if len(blocked) > 0 { addr := tools.Plural(len(blocked), "Address", "Addresses") httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", ")) return } if len(data.To) == 0 { httpError(w, "No valid addresses found") return } reader := bytes.NewReader(msg) m, err := mail.ReadMessage(reader) if err != nil { httpError(w, err.Error()) return } fromAddresses, err := m.Header.AddressList("From") if err != nil { httpError(w, "Failed: unable to parse From header: "+err.Error()) return } if len(fromAddresses) == 0 { httpError(w, "No From header found") return } from := fromAddresses[0].Address // if sender is used, then change from to the sender if senders, err := m.Header.AddressList("Sender"); err == nil { from = senders[0].Address } msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"}) if err != nil { httpError(w, err.Error()) return } // set the Return-Path and SMTP from if config.SMTPRelayConfig.ReturnPath != "" { if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" { msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"}) if err != nil { httpError(w, err.Error()) return } msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...) } from = config.SMTPRelayConfig.ReturnPath } // update message date msg, err = tools.SetMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z)) if err != nil { httpError(w, err.Error()) return } if !config.SMTPRelayConfig.PreserveMessageIDs { // replace the Message-ID header with unique ID uid := shortuuid.New() + "@mailpit" msg, err = tools.SetMessageHeader(msg, "Message-ID", "<"+uid+">") if err != nil { httpError(w, err.Error()) return } } if err := smtpd.Relay(from, data.To, msg); err != nil { 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")) } ================================================ FILE: server/apiv1/send.go ================================================ package apiv1 import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" "net/mail" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/smtpd" "github.com/axllent/mailpit/internal/tools" "github.com/jhillyerd/enmime/v2" ) // SendMessageHandler handles HTTP requests to send a new message func SendMessageHandler(w http.ResponseWriter, r *http.Request) { // swagger:route POST /api/v1/send message SendMessageParams // // # Send a message // // Send a message via the HTTP API. // // Consumes: // - application/json // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: SendMessageResponse // 400: JSONErrorResponse if config.DemoMode { httpJSONError(w, "this functionality has been disabled for demonstration purposes") return } decoder := json.NewDecoder(r.Body) data := sendMessageParams{} if err := decoder.Decode(&data.Body); err != nil { httpJSONError(w, err.Error()) return } var httpAuthUser *string if user, _, ok := r.BasicAuth(); ok { httpAuthUser = &user } id, err := data.Send(r.RemoteAddr, httpAuthUser) if err != nil { httpJSONError(w, err.Error()) return } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(struct{ ID string }{ID: id}); err != nil { httpError(w, err.Error()) } } // Send will validate the message structure and attempt to send to Mailpit. // It returns a sending summary or an error. func (d sendMessageParams) Send(remoteAddr string, httpAuthUser *string) (string, error) { ip, _, err := net.SplitHostPort(remoteAddr) if err != nil { return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error()) } ipAddr := &net.IPAddr{IP: net.ParseIP(ip)} addresses := []string{} msg := enmime.Builder(). From(d.Body.From.Name, d.Body.From.Email). Subject(d.Body.Subject). Text([]byte(d.Body.Text)) if d.Body.HTML != "" { msg = msg.HTML([]byte(d.Body.HTML)) } if len(d.Body.To) > 0 { for _, a := range d.Body.To { if _, err := mail.ParseAddress(a.Email); err == nil { msg = msg.To(a.Name, a.Email) addresses = append(addresses, a.Email) } else { return "", fmt.Errorf("invalid To address: %s", a.Email) } } } if len(d.Body.Cc) > 0 { for _, a := range d.Body.Cc { if _, err := mail.ParseAddress(a.Email); err == nil { msg = msg.CC(a.Name, a.Email) addresses = append(addresses, a.Email) } else { return "", fmt.Errorf("invalid Cc address: %s", a.Email) } } } if len(d.Body.Bcc) > 0 { for _, e := range d.Body.Bcc { if _, err := mail.ParseAddress(e); err == nil { msg = msg.BCC("", e) addresses = append(addresses, e) } else { return "", fmt.Errorf("invalid Bcc address: %s", e) } } } if len(d.Body.ReplyTo) > 0 { for _, a := range d.Body.ReplyTo { if _, err := mail.ParseAddress(a.Email); err == nil { msg = msg.ReplyTo(a.Name, a.Email) } else { return "", fmt.Errorf("invalid Reply-To address: %s", a.Email) } } } restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"} if len(d.Body.Tags) > 0 { msg = msg.Header("X-Tags", strings.Join(d.Body.Tags, ", ")) restrictedHeaders = append(restrictedHeaders, "X-Tags") } if len(d.Body.Headers) > 0 { for k, v := range d.Body.Headers { // check header isn't in "restricted" headers if tools.InArray(k, restrictedHeaders) { return "", fmt.Errorf("cannot overwrite header: \"%s\"", k) } msg = msg.Header(k, v) } } if len(d.Body.Attachments) > 0 { for _, a := range d.Body.Attachments { // workaround: split string because JS readAsDataURL() returns the base64 string // with the mime type prefix eg: data:image/png;base64, parts := strings.Split(a.Content, ",") content := parts[len(parts)-1] b, err := base64.StdEncoding.DecodeString(content) if err != nil { return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error()) } contentType := http.DetectContentType(b) if a.ContentType != "" { contentType = a.ContentType } if a.ContentID != "" { msg = msg.AddInline(b, contentType, a.Filename, a.ContentID) } else { msg = msg.AddAttachment(b, contentType, a.Filename) } } } part, err := msg.Build() if err != nil { return "", fmt.Errorf("error building message: %s", err.Error()) } var buff bytes.Buffer if err := part.Encode(io.Writer(&buff)); err != nil { return "", fmt.Errorf("error building message: %s", err.Error()) } return smtpd.SaveToDatabase(ipAddr, d.Body.From.Email, addresses, buff.Bytes(), httpAuthUser) } ================================================ FILE: server/apiv1/structs.go ================================================ package apiv1 import ( "github.com/axllent/mailpit/internal/storage" ) // 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 ================================================ FILE: server/apiv1/swagger-config.yml ================================================ 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" ================================================ FILE: server/apiv1/swaggerParams.go ================================================ // Package apiv1 provides the API v1 endpoints for Mailpit. // // These structs are for the purpose of defining swagger HTTP parameters in go-swagger // in order to generate a spec file. They are lowercased to avoid exporting them as public types. // //nolint:unused package apiv1 import "github.com/axllent/mailpit/internal/smtpd/chaos" // swagger:parameters setChaosParams type setChaosParams struct { // in: body Body chaos.Triggers } // swagger:parameters AttachmentParams type attachmentParams struct { // Message database ID or "latest" // // in: path // required: true ID string // Attachment part ID // // in: path // required: true PartID string } // swagger:parameters DownloadRawParams type downloadRawParams struct { // Message database ID or "latest" // // in: path // required: true ID string } // swagger:parameters GetMessageParams type getMessageParams struct { // Message database ID or "latest" // // in: path // required: true ID string } // swagger:parameters GetHeadersParams type getHeadersParams struct { // Message database ID or "latest" // // in: path // required: true ID string } // swagger:parameters GetMessagesParams type getMessagesParams struct { // Pagination offset // // in: query // name: start // required: false // default: 0 // type: integer Start int `json:"start"` // Limit number of results // // in: query // name: limit // required: false // default: 50 // type: integer Limit int `json:"limit"` } // swagger:parameters SetReadStatusParams type setReadStatusParams struct { // in: body Body struct { // Read status // // required: false // default: false // example: true Read bool // Optional array of message database IDs // // required: false // default: [] // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] IDs []string // Optional messages matching a search // // required: false // example: tag:backups Search string } // Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland"). // // in: query // required: false // type string TZ string `json:"tz"` } // swagger:parameters DeleteMessagesParams type deleteMessagesParams struct { // Delete request // in: body Body struct { // Array of message database IDs // // required: false // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] IDs []string } } // swagger:parameters SearchParams type searchParams struct { // Search query // // in: query // required: true // type: string Query string `json:"query"` // Pagination offset // // in: query // required: false // default: 0 // type integer Start string `json:"start"` // Limit results // // in: query // required: false // default: 50 // type integer Limit string `json:"limit"` // Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland"). // // in: query // required: false // type string TZ string `json:"tz"` } // swagger:parameters DeleteSearchParams type deleteSearchParams struct { // Search query // // in: query // required: true // type: string Query string `json:"query"` // [Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` & `after:` searches (eg: "Pacific/Auckland"). // // in: query // required: false // type string TZ string `json:"tz"` } // swagger:parameters HTMLCheckParams type htmlCheckParams struct { // Message database ID or "latest" // // in: path // description: Message database ID or "latest" // required: true ID string } // swagger:parameters LinkCheckParams type linkCheckParams struct { // Message database ID or "latest" // // in: path // required: true ID string // Follow redirects // // in: query // required: false // default: false Follow string `json:"follow"` } // swagger:parameters ReleaseMessageParams type releaseMessageParams struct { // Message database ID // // in: path // description: Message database ID // required: true ID string // in: body Body struct { // Array of email addresses to relay the message to // // required: true // example: ["user1@example.com", "user2@example.com"] To []string } } // swagger:parameters SendMessageParams type sendMessageParams struct { // in: body // Body SendRequest Body struct { // "From" recipient // required: true From struct { // Optional name // example: John Doe Name string // Email address // example: john@example.com // required: true Email string } // "To" recipients To []struct { // Optional name // example: Jane Doe Name string // Email address // example: jane@example.com // required: true Email string } // Cc recipients Cc []struct { // Optional name // example: Manager Name string // Email address // example: manager@example.com // required: true Email string } // Bcc recipients email addresses only // example: ["jack@example.com"] Bcc []string // Optional Reply-To recipients ReplyTo []struct { // Optional name // example: Secretary Name string // Email address // example: secretary@example.com // required: true Email string } // Subject // example: Mailpit message via the HTTP API Subject string // Message body (text) // example: Mailpit is awesome! Text string // Message body (HTML) // example:

HTML string // Attachments Attachments []struct { // Base64-encoded string of the file content // required: true // example: iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg== Content string // Filename // required: true // example: mailpit.png Filename string // Optional Content Type for the the attachment. // If this field is not set (or empty) then the content type is automatically detected. // required: false // example: image/png ContentType string // Optional Content-ID (`cid`) for attachment. // If this field is set then the file is attached inline. // required: false // example: mailpit-logo ContentID string } // Mailpit tags // example: ["Tag 1","Tag 2"] Tags []string // Optional headers in {"key":"value"} format // example: {"X-IP":"1.2.3.4"} Headers map[string]string } } // swagger:parameters SetTagsParams type setTagsParams struct { // in: body Body struct { // Array of tag names to set // // required: true // example: ["Tag 1", "Tag 2"] Tags []string // Array of message database IDs // // required: true // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] IDs []string } } // swagger:parameters RenameTagParams type renameTagParams struct { // The url-encoded tag name to rename // // in: path // required: true // type: string Tag string // in: body Body struct { // New name // // required: true // example: New name Name string } } // swagger:parameters DeleteTagParams type deleteTagParams struct { // The url-encoded tag name to delete // // in: path // required: true Tag string } // swagger:parameters GetMessageHTMLParams type getMessageHTMLParams struct { // Message database ID or "latest" // // in: path // required: true ID string // If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target="_blank"` and `rel="noreferrer noopener"` to all links. // // In addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing. // // Note that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing. // // in: query // required: false // type: string Embed string `json:"embed"` } // swagger:parameters GetMessageTextParams type getMessageTextParams struct { // Message database ID or "latest" // // in: path // required: true ID string } // swagger:parameters SpamAssassinCheckParams type spamAssassinCheckParams struct { // Message database ID or "latest" // // in: path // required: true ID string } // swagger:parameters ThumbnailParams type thumbnailParams struct { // Message database ID or "latest" // // in: path // required: true ID string // Attachment part ID // // in: path // required: true PartID string } ================================================ FILE: server/apiv1/swaggerResponses.go ================================================ // Package apiv1 provides the API v1 endpoints for Mailpit. // // These structs are for the purpose of defining swagger HTTP responses in go-swagger // in order to generate a spec file. They are lowercased to avoid exporting them as public types. // //nolint:unused package apiv1 import ( "github.com/axllent/mailpit/internal/smtpd/chaos" "github.com/axllent/mailpit/internal/stats" ) // Binary data response which inherits the attachment's content type. // swagger:response BinaryResponse type binaryResponse string // Plain text response // swagger:response TextResponse type textResponse string // HTML response // swagger:response HTMLResponse type htmlResponse string // Server error will return with a 400 status code // with the error message in the body // swagger:response ErrorResponse type errorResponse string // Not found error will return a 404 status code // swagger:response NotFoundResponse type notFoundResponse string // Plain text "ok" response // swagger:response OKResponse type okResponse string // Plain JSON array response // swagger:response ArrayResponse type arrayResponse []string // JSON error response // swagger:response JSONErrorResponse type jsonErrorResponse struct { // A JSON-encoded error response // // in: body Body struct { // Error message // example: invalid format Error string } } // Web UI configuration response // swagger:response WebUIConfigurationResponse type webUIConfigurationResponse struct { // Web UI configuration settings // // in: body Body struct { // Optional label to identify this Mailpit instance Label string // Message Relay information MessageRelay struct { // Whether message relaying (release) is enabled Enabled bool // The configured SMTP server address SMTPServer string // Enforced Return-Path (if set) for relay bounces ReturnPath string // Only allow relaying to these recipients (regex) AllowedRecipients string // Block relaying to these recipients (regex) BlockedRecipients string // Overrides the "From" address for all relayed messages OverrideFrom string // Preserve the original Message-IDs when relaying messages PreserveMessageIDs bool // DEPRECATED 2024/03/12 // swagger:ignore RecipientAllowlist string } // Whether SpamAssassin is enabled SpamAssassin bool // Whether Chaos support is enabled at runtime ChaosEnabled bool // Whether messages with duplicate IDs are ignored DuplicatesIgnored bool // Whether the delete button should be hidden HideDeleteAllButton bool } } // Application information // swagger:response AppInfoResponse type appInfoResponse struct { // Application information // // in: body Body stats.AppInformation } // Response for the Chaos triggers configuration // swagger:response ChaosResponse type chaosResponse struct { // The current Chaos triggers // // in: body Body chaos.Triggers } // Message headers // swagger:model MessageHeadersResponse type messageHeadersResponse map[string][]string // Summary of messages // swagger:response MessagesSummaryResponse type messagesSummaryResponse struct { // The messages summary // in: body Body MessagesSummary } // Confirmation message for HTTP send API // swagger:response SendMessageResponse type sendMessageResponse struct { // Response for sending messages via the HTTP API // // in: body Body struct { // Database ID // example: iAfZVVe2UQfNSG5BAjgYwa ID string } } ================================================ FILE: server/apiv1/tags.go ================================================ package apiv1 import ( "encoding/json" "net/http" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/websockets" "github.com/gorilla/mux" ) // GetAllTags (method: GET) will get all tags currently in use func GetAllTags(w http.ResponseWriter, _ *http.Request) { // swagger:route GET /api/v1/tags tags GetAllTags // // # Get all current tags // // Returns a JSON array of all unique message tags. // // Produces: // - application/json // // Schemes: http, https // // Responses: // 200: ArrayResponse // 400: ErrorResponse w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil { httpError(w, err.Error()) } } // SetMessageTags (method: PUT) will set the tags for all provided IDs func SetMessageTags(w http.ResponseWriter, r *http.Request) { // swagger:route PUT /api/v1/tags tags SetTagsParams // // # Set message tags // // This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array. // // Consumes: // - application/json // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse decoder := json.NewDecoder(r.Body) var data struct { Tags []string IDs []string } err := decoder.Decode(&data) if err != nil { httpError(w, err.Error()) return } ids := data.IDs if len(ids) > 0 { for _, id := range ids { if _, err := storage.SetMessageTags(id, data.Tags); err != nil { httpError(w, err.Error()) return } } } w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } // RenameTag (method: PUT) used to rename a tag func RenameTag(w http.ResponseWriter, r *http.Request) { // swagger:route PUT /api/v1/tags/{Tag} tags RenameTagParams // // # Rename a tag // // Renames an existing tag. // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse vars := mux.Vars(r) tag := vars["tag"] decoder := json.NewDecoder(r.Body) var data struct { Name string } err := decoder.Decode(&data) if err != nil { httpError(w, err.Error()) return } if err := storage.RenameTag(tag, data.Name); err != nil { httpError(w, err.Error()) return } websockets.Broadcast("prune", nil) w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } // DeleteTag (method: DELETE) used to delete a tag func DeleteTag(w http.ResponseWriter, r *http.Request) { // swagger:route DELETE /api/v1/tags/{Tag} tags DeleteTagParams // // # Delete a tag // // Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag. // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: OKResponse // 400: ErrorResponse vars := mux.Vars(r) tag := vars["tag"] if err := storage.DeleteTag(tag); err != nil { httpError(w, err.Error()) return } websockets.Broadcast("prune", nil) w.Header().Add("Content-Type", "text/plain") _, _ = w.Write([]byte("ok")) } ================================================ FILE: server/apiv1/testing.go ================================================ package apiv1 import ( "bytes" "fmt" "net/http" "regexp" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/gorilla/mux" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // GetMessageHTML (method: GET) returns a rendered version of a message's HTML part func GetMessageHTML(w http.ResponseWriter, r *http.Request) { // swagger:route GET /view/{ID}.html testing GetMessageHTMLParams // // # Render message HTML part // // Renders just the message's HTML part which can be used for UI integration testing. // Attached inline images are modified to link to the API provided they exist. // Note that is the message does not contain a HTML part then an 404 error is returned. // // The ID can be set to `latest` to return the latest message. // // Produces: // - text/html // // Schemes: http, https // // Responses: // 200: HTMLResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } msg, err := storage.GetMessage(id) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, "Message not found") return } if msg.HTML == "" { w.WriteHeader(404) _, _ = fmt.Fprint(w, "This message does not contain a HTML part") return } htmlStr := linkInlineImages(msg) // If embed=1 is set, then we will add target="_blank" and rel="noreferrer noopener" to all links if r.URL.Query().Get("embed") == "1" { doc, err := html.Parse(strings.NewReader(htmlStr)) if err != nil { logger.Log().Error(err.Error()) } else { // Walk the entire HTML tree. tools.WalkHTML(doc, func(n *html.Node) { if n.Type == html.ElementNode && n.DataAtom == atom.A { // Set attributes on all anchors with external links. tools.SetHTMLAttributeVal(n, "target", "_blank") tools.SetHTMLAttributeVal(n, "rel", "noreferrer noopener") } }) b := bytes.Buffer{} _ = html.Render(&b, doc) htmlStr = b.String() nonce := r.Header.Get("mp-nonce") js := `` htmlStr = strings.ReplaceAll(htmlStr, "", js+"") } } w.Header().Add("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(htmlStr)) } // GetMessageText (method: GET) returns a message's text part func GetMessageText(w http.ResponseWriter, r *http.Request) { // swagger:route GET /view/{ID}.txt testing GetMessageTextParams // // # Render message text part // // Renders just the message's text part which can be used for UI integration testing. // // The ID can be set to `latest` to return the latest message. // // Produces: // - text/plain // // Schemes: http, https // // Responses: // 200: TextResponse // 400: ErrorResponse // 404: NotFoundResponse vars := mux.Vars(r) id := vars["id"] if id == "latest" { var err error id, err = storage.LatestID(r) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, err.Error()) return } } msg, err := storage.GetMessage(id) if err != nil { w.WriteHeader(404) _, _ = fmt.Fprint(w, "Message not found") return } w.Header().Add("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(msg.Text)) } // This will rewrite all inline image paths to API URLs func linkInlineImages(msg *storage.Message) string { html := msg.HTML for _, a := range msg.Inline { if a.ContentID != "" { re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID matches := re.FindAllStringSubmatch(html, -1) for _, m := range matches { html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) } } } for _, a := range msg.Attachments { if a.ContentID != "" { re := regexp.MustCompile(`(?i)(=["\']?)(cid:` + regexp.QuoteMeta(a.ContentID) + `)(["|\'|\\s|\\/|>|;])`) u := config.Webroot + "api/v1/message/" + msg.ID + "/part/" + a.PartID matches := re.FindAllStringSubmatch(html, -1) for _, m := range matches { html = strings.ReplaceAll(html, m[0], m[1]+u+m[3]) } } } return html } ================================================ FILE: server/apiv1/thumbnails.go ================================================ package apiv1 import ( "bufio" "bytes" "image" "image/color" "image/draw" "image/jpeg" "net/http" "strings" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/gorilla/mux" "github.com/jhillyerd/enmime/v2" "github.com/kovidgoyal/imaging" ) var ( thumbWidth = 180 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 ThumbnailParams // // # Get an attachment image thumbnail // // This will return a cropped 180x120 JPEG thumbnail of an image attachment. // If the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned. // // The ID can be set to `latest` to return the latest message. // // Produces: // - image/jpeg // // Schemes: http, https // // Responses: // 200: BinaryResponse // 400: ErrorResponse 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 } if !strings.HasPrefix(a.ContentType, "image/") { blankImage(a, w) return } buf := bytes.NewBuffer(a.Content) img, err := imaging.Decode(buf, imaging.AutoOrientation(true)) if err != nil { // it's not an image, return default logger.Log().Warnf("[image] %s", err.Error()) blankImage(a, w) return } var b bytes.Buffer foo := bufio.NewWriter(&b) var temp image.Image if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight { temp = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos) } else { temp = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) } dstImageFill := imaging.Clone(temp) // create white image and paste image over the top // preventing black backgrounds for transparent GIF/PNG images dst := imaging.New(thumbWidth, thumbHeight, color.White) // paste the original over the top dst = imaging.OverlayCenter(dst, dstImageFill, 1.0) if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil { logger.Log().Warnf("[image] %s", err.Error()) blankImage(a, w) return } w.Header().Add("Content-Type", "image/jpeg") w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") _, _ = w.Write(b.Bytes()) } // Return a blank image instead of an error when file or image not supported func blankImage(a *enmime.Part, w http.ResponseWriter) { rect := image.Rect(0, 0, thumbWidth, thumbHeight) img := image.NewRGBA(rect) background := color.RGBA{255, 255, 255, 255} draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src) var b bytes.Buffer foo := bufio.NewWriter(&b) dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil { logger.Log().Warnf("[image] %s", err.Error()) } fileName := a.FileName if fileName == "" { fileName = a.ContentID } w.Header().Add("Content-Type", "image/jpeg") w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") _, _ = w.Write(b.Bytes()) } ================================================ FILE: server/cors.go ================================================ package server import ( "net/http" "net/url" "sort" "strings" "github.com/axllent/mailpit/internal/logger" ) var ( // AccessControlAllowOrigin CORS policy - set with flags/env AccessControlAllowOrigin string // CorsAllowOrigins are optional allowed origins by hostname, set via setCORSOrigins(). corsAllowOrigins = make(map[string]bool) ) // equalASCIIFold reports whether s and t, interpreted as UTF-8 strings, are equal // under Unicode case folding, ignoring any difference in length. func asciiFoldString(s string) string { b := make([]byte, len(s)) for i := range s { b[i] = toLowerASCIIFold(s[i]) } return string(b) } // toLowerASCIIFold returns the Unicode case-folded equivalent of the ASCII character c. // It is equivalent to the Unicode 13.0.0 function foldCase(c, CaseFoldingMapping). func toLowerASCIIFold(c byte) byte { if 'A' <= c && c <= 'Z' { return c + 'a' - 'A' } return c } // CorsOriginAccessControl checks if the request origin is allowed based on the configured CORS origins. func corsOriginAccessControl(r *http.Request) bool { origin := r.Header["Origin"] if len(origin) != 0 { u, err := url.Parse(origin[0]) if err != nil { logger.Log().Errorf("[cors] origin parse error: %v", err) return false } _, allAllowed := corsAllowOrigins["*"] // allow same origin, or if "*" is defined as an origin if asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed { return true } // match on full host:port so that example.com:8080 is not admitted // by an allowlist entry for example.com (standard port 80/443). originHostFold := asciiFoldString(u.Host) if corsAllowOrigins[originHostFold] { return true } logger.Log().Warnf("[cors] blocking request from unauthorized origin: %s", u.Host) return false } return true } // SetCORSOrigins sets the allowed CORS origins from a comma-separated string. // Origins are matched on the full host:port, so example.com and example.com:8080 // are treated as distinct origins. func setCORSOrigins() { corsAllowOrigins = make(map[string]bool) hosts := extractOrigins(AccessControlAllowOrigin) for _, host := range hosts { corsAllowOrigins[asciiFoldString(host)] = true } if _, wildCard := corsAllowOrigins["*"]; wildCard { // reset to just wildcard corsAllowOrigins = make(map[string]bool) corsAllowOrigins["*"] = true logger.Log().Info("[cors] all origins are allowed due to wildcard \"*\"") } else { keys := make([]string, 0) for k := range corsAllowOrigins { keys = append(keys, k) } sort.Strings(keys) logger.Log().Infof("[cors] allowed API origins: %v", strings.Join(keys, ", ")) } } // extractOrigins extracts and returns a sorted list of origins from a comma-separated string. func extractOrigins(str string) []string { origins := make([]string, 0) s := strings.TrimSpace(str) if s == "" { return origins } hosts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) for _, host := range hosts { h := strings.TrimSpace(host) if h != "" { if h == "*" { return []string{"*"} } if !strings.HasPrefix(h, "http://") && !strings.HasPrefix(h, "https://") { h = "http://" + h } u, err := url.Parse(h) if err != nil || u.Hostname() == "" || strings.Contains(h, "*") { logger.Log().Warnf("[cors] invalid CORS origin \"%s\", ignoring", h) continue } // Store host:port so port differences are respected. // u.Host equals u.Hostname() when no port is present. origins = append(origins, u.Host) } } sort.Strings(origins) return origins } ================================================ FILE: server/cors_test.go ================================================ package server import ( "net/http" "testing" ) func TestExtractOrigins(t *testing.T) { tests := []struct { name string input string expected []string }{ { name: "empty string", input: "", expected: []string{}, }, { name: "single hostname", input: "example.com", expected: []string{"example.com"}, }, { name: "multiple hostnames comma separated", input: "example.com,foo.com", expected: []string{"example.com", "foo.com"}, }, { name: "multiple hostnames space separated", input: "example.com foo.com", expected: []string{"example.com", "foo.com"}, }, { name: "wildcard", input: "*", expected: []string{"*"}, }, { name: "mixed protocols", input: "http://example.com,https://foo.com:8080", expected: []string{"example.com", "foo.com:8080"}, }, { name: "embedded wildcard", input: "http://example.com,*,https://test", expected: []string{"*"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := extractOrigins(tt.input) if len(got) != len(tt.expected) { t.Errorf("expected %d origins, got %d", len(tt.expected), len(got)) return } for i := range got { if got[i] != tt.expected[i] { t.Errorf("expected origin %q, got %q", tt.expected[i], got[i]) } } }) } } func TestCorsOriginAccessControl(t *testing.T) { // Setup allowed origins AccessControlAllowOrigin = "example.com,foo.com,bar.com" setCORSOrigins() tests := []struct { name string origin string host string allow bool }{ {"no origin header", "", "example.com", true}, // example.com:1234 must NOT be admitted by an allowlist entry for example.com (different port) {"allowed origin", "http://example.com:1234", "mailpit.local", false}, {"allowed origin", "http://example.com:1234", "example.com", false}, {"allowed origin", "http://example.com:1234", "example.com:1234", true}, {"not allowed origin", "http://notallowed.com", "mailpit.local", false}, {"allowed by hostname", "http://foo.com", "mailpit.local", true}, {"ascii fold: allowed origin uppercase", "HTTP://EXAMPLE.COM", "mailpit.local", true}, {"ascii fold: allowed by hostname uppercase", "HTTP://FOO.COM", "mailpit.local", true}, {"ascii fold: host uppercase", "http://example.com", "MAILPIT.LOCAL", true}, {"ascii fold: not allowed origin uppercase", "HTTP://NOTALLOWED.COM", "mailpit.local", false}, {"ascii fold: mixed case", "HtTp://ExAmPlE.CoM", "mailpit.local", true}, {"non-ascii: allowed origin (unicode hostname)", "http://exámple.com", "mailpit.local", false}, {"non-ascii: allowed by hostname (unicode)", "http://föö.com", "mailpit.local", false}, {"non-ascii: host uppercase (unicode)", "http://exámple.com", "MAILPIT.LOCAL", false}, {"non-ascii: mixed case (unicode)", "HtTp://ExÁmPlE.CoM", "mailpit.local", false}, } // Add wildcard test AccessControlAllowOrigin = "*" setCORSOrigins() reqWildcard := &http.Request{Header: http.Header{"Origin": {"http://any.com"}}, Host: "mailpit.local"} if !corsOriginAccessControl(reqWildcard) { t.Error("Wildcard origin should be allowed") } // Reset to specific hosts AccessControlAllowOrigin = "example.com,foo.com,bar.com" setCORSOrigins() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := &http.Request{Header: http.Header{}, Host: tt.host} if tt.origin != "" { req.Header.Set("Origin", tt.origin) } allowed := corsOriginAccessControl(req) if allowed != tt.allow { t.Errorf("expected allowed=%v, got %v for origin=%q host=%q", tt.allow, allowed, tt.origin, tt.host) } }) } } ================================================ FILE: server/embed.go ================================================ package server import ( "embed" "net/http" "path" "strings" "github.com/axllent/mailpit/config" ) var ( //go:embed ui distFS embed.FS ) // EmbedController is a simple controller to return a file from the embedded filesystem. // // This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes // the Content-Encoding header from error responses, breaking pages such as 404's while // using gzip compression middleware. func embedController(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if strings.HasSuffix(p, "/") { p = p + "index.html" } p = strings.TrimPrefix(p, config.Webroot) // server webroot config p = path.Join("ui", p) // add go:embed path to path prefix b, err := distFS.ReadFile(p) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } // ensure any HTML files have the correct nonce if strings.HasSuffix(p, ".html") { nonce := r.Header.Get("mp-nonce") b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce)) } // allow browser cache except for ?dev queries and HTML files if r.URL.RawQuery != "dev" && !strings.HasSuffix(p, ".html") { w.Header().Set("Cache-Control", "max-age=31536000, public, immutable") } w.Header().Set("Content-Type", contentType(p)) _, _ = w.Write(b) } // ContentType supports only a few content types, limited to this application's needs. func contentType(p string) string { switch { case strings.HasSuffix(p, ".html"): return "text/html; charset=utf-8" case strings.HasSuffix(p, ".css"): return "text/css; charset=utf-8" case strings.HasSuffix(p, ".js"): return "application/javascript; charset=utf-8" case strings.HasSuffix(p, ".json"): return "application/json" case strings.HasSuffix(p, ".svg"): return "image/svg+xml" case strings.HasSuffix(p, ".ico"): return "image/x-icon" case strings.HasSuffix(p, ".png"): return "image/png" case strings.HasSuffix(p, ".jpg"): return "image/jpeg" case strings.HasSuffix(p, ".gif"): return "image/gif" case strings.HasSuffix(p, ".woff"): return "font/woff" case strings.HasSuffix(p, ".woff2"): return "font/woff2" default: return "text/plain" } } ================================================ FILE: server/handlers/k8healthz.go ================================================ package handlers import "net/http" // HealthzHandler is a liveness probe func HealthzHandler(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } ================================================ FILE: server/handlers/k8sready.go ================================================ package handlers import ( "net/http" "sync/atomic" "github.com/axllent/mailpit/internal/storage" ) // ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { if isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) } } ================================================ FILE: server/handlers/messages.go ================================================ package handlers import ( "net/http" "net/url" "strings" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/storage" ) // RedirectToLatestMessage (method: GET) redirects the web UI to the latest message func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) { var messages []storage.MessageSummary var err error search := strings.TrimSpace(r.URL.Query().Get("query")) if search != "" { messages, _, err = storage.Search(search, "", 0, 0, 1) if err != nil { httpError(w, err.Error()) return } } else { messages, err = storage.List(0, 0, 1) if err != nil { httpError(w, err.Error()) return } } uri := config.Webroot if len(messages) == 1 { uri, err = url.JoinPath(uri, "/view/"+messages[0].ID) if err != nil { httpError(w, err.Error()) return } } http.Redirect(w, r, uri, http.StatusFound) } ================================================ FILE: server/handlers/proxy.go ================================================ // Package handlers contains a specific handlers package handlers import ( "context" "crypto/tls" "encoding/base64" "errors" "fmt" "io" "net" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/PuerkitoBio/goquery" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" ) const ( // maxProxyBodySize is the maximum number of bytes read from a proxied // response body (fonts, images, CSS). Prevents OOM on oversized responses. maxProxyBodySize = 50 * 1024 * 1024 // 50 MB ) var ( linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) urlRe = regexp.MustCompile(`(?mU)url\(('|")?(https?:\/\/[^)'"]+)('|")?\)`) assetsMutex sync.Mutex assets = map[string]MessageAssets{} ) // MessageAssets represents assets linked in a message type MessageAssets struct { ID string // Created timestamp so we can expire old entries Created time.Time // Assets found in the message Assets []string } func init() { // Start a goroutine to clean up old asset entries every minute go func() { for { time.Sleep(time.Minute) assetsMutex.Lock() now := time.Now() for id, entry := range assets { if now.Sub(entry.Created) > time.Minute { logger.Log().Debugf("[proxy] cleaning up assets for message %s", id) delete(assets, id) } } assetsMutex.Unlock() } }() } // ProxyHandler is used to proxy assets for printing. // It accepts a base64-encoded message-id:url string as the `data` query parameter. func ProxyHandler(w http.ResponseWriter, r *http.Request) { encoded := strings.TrimSpace(r.URL.Query().Get("data")) if encoded == "" { logger.Log().Warn("[proxy] Data missing") httpError(w, "Error: Data missing") return } decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { logger.Log().Warnf("[proxy] Data parameter corrupted: %s", err.Error()) httpError(w, "Error: invalid request") return } parts := strings.SplitN(string(decoded), ":", 2) if len(parts) != 2 { logger.Log().Warnf("[proxy] Invalid data parameter: %s", string(decoded)) httpError(w, "Error: invalid request") return } id := parts[0] uri := parts[1] links, err := getAssets(id) if err != nil { httpError(w, "Error: invalid request") return } if !tools.InArray(uri, links) { logger.Log().Warnf("[proxy] URL %s not found in message %s", uri, id) httpError(w, "Error: invalid request") return } if !linkRe.MatchString(uri) || !tools.IsValidLinkURL(uri) { logger.Log().Warnf("[proxy] invalid URL %s", uri) httpError(w, "Error: invalid URL") return } dialer := &net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, } tr := &http.Transport{ DialContext: safeDialContext(dialer), } if config.AllowUntrustedTLS { tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec } client := &http.Client{ Timeout: 10 * time.Second, Transport: tr, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("too many redirects") } if !tools.IsValidLinkURL(req.URL.String()) { return fmt.Errorf("blocked redirect to invalid URL: %s", req.URL) } return nil }, } req, err := http.NewRequest("GET", uri, nil) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) httpError(w, "Error: invalid request") return } // use requesting useragent req.Header.Set("User-Agent", r.UserAgent()) resp, err := client.Do(req) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) httpError(w, "Error: invalid request") return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { logger.Log().Warnf("[proxy] received status code %d for %s", resp.StatusCode, uri) httpError(w, "Error: invalid request") return } ct := strings.ToLower(resp.Header.Get("content-type")) if !supportedProxyContentType(ct) { logger.Log().Warnf("[proxy] blocking unsupported content-type %s for %s", ct, uri) httpError(w, "Error: invalid request") return } limitedBody := io.LimitReader(resp.Body, maxProxyBodySize+1) body, err := io.ReadAll(limitedBody) if err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) httpError(w, "Error: invalid request") return } if int64(len(body)) > maxProxyBodySize { logger.Log().Warnf("[proxy] response body for %s exceeds %d bytes, blocking", uri, maxProxyBodySize) httpError(w, "Error: response too large") return } // relay common headers w.Header().Set("content-type", ct) if resp.Header.Get("last-modified") != "" { w.Header().Set("last-modified", resp.Header.Get("last-modified")) } if resp.Header.Get("content-disposition") != "" { w.Header().Set("content-disposition", resp.Header.Get("content-disposition")) } if resp.Header.Get("cache-control") != "" { w.Header().Set("cache-control", resp.Header.Get("cache-control")) } // replace CSS url() values with proxy address, eg: fonts & images if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") { var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`) body = re.ReplaceAllFunc(body, func(s []byte) []byte { parts := re.FindStringSubmatch(string(s)) // don't resolve inline `data:..` if strings.HasPrefix(parts[3], "data:") { return []byte(parts[3]) } address, err := absoluteURL(parts[3], uri) if err != nil { logger.Log().Errorf("[proxy] %s", err.Error()) return []byte(parts[3]) } // store asset address against message ID if result, ok := assets[id]; ok { if !tools.InArray(address, result.Assets) { assetsMutex.Lock() result.Assets = append(result.Assets, address) assets[id] = result assetsMutex.Unlock() } } // encode with base64 to handle any special characters and group message ID with URL encoded := base64.StdEncoding.EncodeToString([]byte(id + ":" + address)) return []byte("url(" + parts[2] + config.Webroot + "proxy?data=" + encoded + parts[4] + ")") }) } logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode) // relay status code - WriteHeader must come after Header.Set() w.WriteHeader(resp.StatusCode) if _, err := w.Write(body); err != nil { logger.Log().Warnf("[proxy] %s", err.Error()) } } // GetAssets retrieves and parses the message to return linked assets. // Linked CSS files are appended to the assets list via the ProxyHandler when proxying CSS files. func getAssets(id string) ([]string, error) { assetsMutex.Lock() defer assetsMutex.Unlock() result, ok := assets[id] if ok { // return cached assets return result.Assets, nil } msg, err := storage.GetMessage(id) if err != nil { return nil, err } links := []string{} reader := strings.NewReader(msg.HTML) // load the HTML document doc, err := goquery.NewDocumentFromReader(reader) if err != nil { return nil, err } // css & font links doc.Find("link").Each(func(_ int, s *goquery.Selection) { if href, exists := s.Attr("href"); exists { if linkRe.MatchString(href) && !tools.InArray(href, links) { links = append(links, href) } } }) // images doc.Find("img").Each(func(_ int, s *goquery.Selection) { if src, exists := s.Attr("src"); exists { if linkRe.MatchString(src) && !tools.InArray(src, links) { links = append(links, src) } } }) // background="<>" links doc.Find("[background]").Each(func(_ int, s *goquery.Selection) { if bg, exists := s.Attr("background"); exists { if linkRe.MatchString(bg) && !tools.InArray(bg, links) { links = append(links, bg) } } }) // url(<>) links in style blocks matches := urlRe.FindAllStringSubmatch(msg.HTML, -1) for _, match := range matches { if len(match) >= 3 { link := match[2] if linkRe.MatchString(link) && !tools.InArray(link, links) { links = append(links, link) } } } r := MessageAssets{} r.ID = id r.Created = time.Now() r.Assets = links assets[id] = r return links, nil } // AbsoluteURL will return a full URL regardless whether it is relative or absolute. // This is used to replace relative CSS url(...) links when proxying. func absoluteURL(link, baseURL string) (string, error) { // scheme relative links, eg ` t, err := template.New("index").Parse(h) if err != nil { panic(err) } data := struct { Webroot string Version string Nonce string }{ Webroot: config.Webroot, Version: config.Version, Nonce: r.Header.Get("mp-nonce"), } buff := new(bytes.Buffer) err = t.Execute(buff, data) if err != nil { panic(err) } w.Header().Add("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(buff.Bytes()) } ================================================ FILE: server/server_test.go ================================================ package server import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/apiv1" "github.com/jhillyerd/enmime/v2" "golang.org/x/crypto/bcrypt" ) var ( putDataStruct struct { Read bool IDs []string } // Shared test message structure for consistency testSendMessage = map[string]any{ "From": map[string]string{ "Email": "test@example.com", }, "To": []map[string]string{ {"Email": "recipient@example.com"}, }, "Subject": "Test", "Text": "Test message", } ) func TestAPIv1Messages(t *testing.T) { setup() defer storage.Close() r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() m, err := fetchMessages(ts.URL + "/api/v1/messages") if err != nil { t.Error(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) m, err = fetchMessages(ts.URL + "/api/v1/messages") if err != nil { t.Error(err.Error()) } // read first 10 messages t.Log("Read first 10 messages including raw & headers") for idx, msg := range m.Messages { if idx == 10 { break } if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID); err != nil { t.Error(err.Error()) } // get RAW if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/raw"); err != nil { t.Error(err.Error()) } // get headers if _, err := clientGet(ts.URL + "/api/v1/message/" + msg.ID + "/headers"); err != nil { t.Error(err.Error()) } } // 10 should be marked as read assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100) // 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 TestAPIv1ToggleReadStatus(t *testing.T) { setup() defer storage.Close() r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() m, err := fetchMessages(ts.URL + "/api/v1/messages") if err != nil { t.Error(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) m, err = fetchMessages(ts.URL + "/api/v1/messages") if err != nil { t.Error(err.Error()) } // read first 10 IDs t.Log("Get first 10 IDs") putIDs := []string{} for idx, msg := range m.Messages { if idx == 10 { break } // store for later putIDs = append(putIDs, msg.ID) } assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100) // mark first 10 as unread t.Log("Mark first 10 as read") putData := putDataStruct putData.Read = true putData.IDs = putIDs j, err := json.Marshal(putData) if err != nil { t.Error(err.Error()) } _, err = clientPut(ts.URL+"/api/v1/messages", string(j)) if err != nil { t.Error(err.Error()) } assertStatsEqual(t, ts.URL+"/api/v1/messages", 90, 100) // mark first 10 as read t.Log("Mark first 10 as unread") putData.Read = false j, err = json.Marshal(putData) if err != nil { t.Error(err.Error()) } _, err = clientPut(ts.URL+"/api/v1/messages", string(j)) if err != nil { t.Error(err.Error()) } assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100) // mark all as read putData.Read = true putData.IDs = []string{} j, err = json.Marshal(putData) if err != nil { t.Error(err.Error()) } t.Log("Mark all read") _, err = clientPut(ts.URL+"/api/v1/messages", string(j)) if err != nil { t.Error(err.Error()) } assertStatsEqual(t, ts.URL+"/api/v1/messages", 0, 100) } func TestAPIv1Search(t *testing.T) { setup() defer storage.Close() r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() // insert 100 t.Log("Insert 100 messages & tag") insertEmailData(t) assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100) // search assertSearchEqual(t, ts.URL+"/api/v1/search", "from-1@example.com", 1) assertSearchEqual(t, ts.URL+"/api/v1/search", "from:from-1@example.com", 1) assertSearchEqual(t, ts.URL+"/api/v1/search", "-from:from-1@example.com", 99) assertSearchEqual(t, ts.URL+"/api/v1/search", "-FROM:FROM-1@EXAMPLE.COM", 99) 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) assertSearchEqual(t, ts.URL+"/api/v1/search", "tag:\"Test tag 065\"", 1) assertSearchEqual(t, ts.URL+"/api/v1/search", "tag:\"TEST TAG 065\"", 1) assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99) } func TestAPIv1Send(t *testing.T) { setup() defer storage.Close() r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() jsonData := `{ "From": { "Email": "john@example.com", "Name": "John Doe" }, "To": [ { "Email": "jane@example.com", "Name": "Jane Doe" } ], "Cc": [ { "Email": "manager1@example.com", "Name": "Manager 1" }, { "Email": "manager2@example.com", "Name": "Manager 2" } ], "Bcc": ["jack@example.com"], "Headers": { "X-IP": "1.2.3.4" }, "Subject": "Mailpit message via the HTTP API", "Text": "This is the text body", "HTML": "

Mailpit is awesome!

", "Attachments": [ { "Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==", "Filename": "Attached File.txt" }, { "Content": "iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==", "Filename": "logo.png", "ContentID": "inline-cid", "ContentType": "overridden/type" } ], "ReplyTo": [ { "Email": "secretary@example.com", "Name": "Secretary" } ], "Tags": [ "Tag 1", "Tag 2" ] }` t.Log("Sending message via HTTP API") b, err := clientPost(ts.URL+"/api/v1/send", jsonData) if err != nil { t.Errorf("Expected nil, received %s", err.Error()) } resp := struct { ID string }{} if err := json.Unmarshal(b, &resp); err != nil { t.Error(err.Error()) return } t.Logf("Fetching response for message %s", resp.ID) msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID) if err != nil { t.Error(err.Error()) } t.Logf("Testing response for message %s", resp.ID) assertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, "wrong subject") assertEqual(t, `This is the text body`, msg.Text, "wrong text") assertEqual(t, `

Mailpit is awesome!

`, msg.HTML, "wrong HTML") assertEqual(t, `"John Doe" `, msg.From.String(), "wrong HTML") assertEqual(t, 1, len(msg.To), "wrong To count") assertEqual(t, `"Jane Doe" `, msg.To[0].String(), "wrong To address") assertEqual(t, 2, len(msg.Cc), "wrong Cc count") assertEqual(t, `"Manager 1" `, msg.Cc[0].String(), "wrong Cc address") assertEqual(t, `"Manager 2" `, msg.Cc[1].String(), "wrong Cc address") assertEqual(t, 1, len(msg.Bcc), "wrong Bcc count") assertEqual(t, ``, msg.Bcc[0].String(), "wrong Bcc address") assertEqual(t, 1, len(msg.ReplyTo), "wrong Reply-To count") assertEqual(t, `"Secretary" `, msg.ReplyTo[0].String(), "wrong Reply-To address") assertEqual(t, 2, len(msg.Tags), "wrong Tags count") assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags") assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count") assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name") assertEqual(t, `text/plain`, msg.Attachments[0].ContentType, "wrong Content-Type") assertEqual(t, 1, len(msg.Inline), "wrong inline Attachment count") assertEqual(t, `logo.png`, msg.Inline[0].FileName, "wrong Attachment name") assertEqual(t, `overridden/type`, msg.Inline[0].ContentType, "wrong Content-Type") attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID) if err != nil { t.Error(err.Error()) } assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content") } func TestSendAPIAuthMiddleware(t *testing.T) { setup() defer storage.Close() // Test 1: Send API with accept-any enabled (should bypass all auth) t.Run("SendAPIAuthAcceptAny", func(t *testing.T) { // Set up UI auth and enable accept-any for send API originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny originalUICredentials := auth.UICredentials defer func() { config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny auth.UICredentials = originalUICredentials }() // Enable accept-any for send API config.SendAPIAuthAcceptAny = true // Set up UI auth that would normally block requests testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost) if err := auth.SetUIAuth("testuser:" + string(testHash)); err != nil { t.Fatalf("Failed to set UI auth: %s", err.Error()) } r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() // Should succeed without any auth headers jsonData, _ := json.Marshal(testSendMessage) _, err := clientPost(ts.URL+"/api/v1/send", string(jsonData)) if err != nil { t.Errorf("Expected send to succeed with accept-any, got error: %s", err.Error()) } }) // Test 2: Send API with dedicated credentials t.Run("SendAPIWithDedicatedCredentials", func(t *testing.T) { originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny originalUICredentials := auth.UICredentials originalSendAPICredentials := auth.SendAPICredentials defer func() { config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny auth.UICredentials = originalUICredentials auth.SendAPICredentials = originalSendAPICredentials }() config.SendAPIAuthAcceptAny = false // Set up UI auth uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost) if err := auth.SetUIAuth("uiuser:" + string(uiHash)); err != nil { t.Fatalf("Failed to set UI auth: %s", err.Error()) } // Set up dedicated Send API auth sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost) if err := auth.SetSendAPIAuth("senduser:" + string(sendHash)); err != nil { t.Fatalf("Failed to set Send API auth: %s", err.Error()) } r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() jsonData, _ := json.Marshal(testSendMessage) // Should succeed with correct Send API credentials _, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "sendpass") if err != nil { t.Errorf("Expected send to succeed with correct Send API credentials, got error: %s", err.Error()) } // Should fail with wrong Send API credentials _, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "wrongpass") if err == nil { t.Error("Expected send to fail with wrong Send API credentials") } // Should fail with UI credentials when Send API credentials are set _, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass") if err == nil { t.Error("Expected send to fail with UI credentials when Send API credentials are required") } }) // Test 3: Send API fallback to UI auth when no Send API auth is configured t.Run("SendAPIFallbackToUIAuth", func(t *testing.T) { originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny originalUICredentials := auth.UICredentials originalSendAPICredentials := auth.SendAPICredentials defer func() { config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny auth.UICredentials = originalUICredentials auth.SendAPICredentials = originalSendAPICredentials }() config.SendAPIAuthAcceptAny = false auth.SendAPICredentials = nil // Set up only UI auth uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost) if err := auth.SetUIAuth("uiuser:" + string(uiHash)); err != nil { t.Fatalf("Failed to set UI auth: %s", err.Error()) } r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() jsonData, _ := json.Marshal(testSendMessage) // Should succeed with UI credentials when no Send API auth is configured _, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass") if err != nil { t.Errorf("Expected send to succeed with UI credentials when no Send API auth configured, got error: %s", err.Error()) } // Should fail without any credentials _, err = clientPost(ts.URL+"/api/v1/send", string(jsonData)) if err == nil { t.Error("Expected send to fail without credentials when UI auth is required") } }) // Test 4: Regular API endpoints should not be affected by Send API auth settings t.Run("RegularAPINotAffectedBySendAPIAuth", func(t *testing.T) { originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny originalUICredentials := auth.UICredentials originalSendAPICredentials := auth.SendAPICredentials defer func() { config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny auth.UICredentials = originalUICredentials auth.SendAPICredentials = originalSendAPICredentials }() // Set up UI auth and Send API auth uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost) if err := auth.SetUIAuth("uiuser:" + string(uiHash)); err != nil { t.Fatalf("Failed to set UI auth: %s", err.Error()) } sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost) if err := auth.SetSendAPIAuth("senduser:" + string(sendHash)); err != nil { t.Fatalf("Failed to set Send API auth: %s", err.Error()) } r := apiRoutes() ts := httptest.NewServer(r) defer ts.Close() // Regular API endpoint should require UI credentials, not Send API credentials _, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "uiuser", "uipass") if err != nil { t.Errorf("Expected regular API to work with UI credentials, got error: %s", err.Error()) } // Regular API endpoint should fail with Send API credentials _, err = clientGetWithAuth(ts.URL+"/api/v1/messages", "senduser", "sendpass") if err == nil { t.Error("Expected regular API to fail with Send API credentials") } }) } func setup() { logger.NoLogging = true config.MaxMessages = 0 config.Database = os.Getenv("MP_DATABASE") if err := storage.InitDB(); err != nil { panic(err) } if err := storage.DeleteAllMessages(); 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.Error(err.Error()) return } if err := json.Unmarshal(data, &m); err != nil { t.Error(err.Error()) return } assertEqual(t, uint64(unread), m.Unread, "wrong unread count") assertEqual(t, uint64(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.Error(err.Error()) return } if err := json.Unmarshal(data, &m); err != nil { t.Error(err.Error()) return } assertEqual(t, uint64(count), m.MessagesCount, "wrong search results count") } func insertEmailData(t *testing.T) { for i := range 100 { msg := enmime.Builder(). From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)). Subject(fmt.Sprintf("Subject line %d end", i)). Text(fmt.Appendf(nil, "This is the email body %d .", i)). To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i)) env, err := msg.Build() if err != nil { t.Log("error ", err) t.Fail() } buf := new(bytes.Buffer) if err := env.Encode(buf); err != nil { t.Log("error ", err) t.Fail() } bufBytes := buf.Bytes() id, err := storage.Store(&bufBytes, nil) if err != nil { t.Log("error ", err) t.Fail() } if _, err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil { t.Log("error ", err) t.Fail() } } } func fetchMessage(url string) (storage.Message, error) { m := storage.Message{} 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 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 func() { _ = resp.Body.Close() }() data, err := io.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 func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) } data, err := io.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 func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) } data, err := io.ReadAll(resp.Body) return data, err } func clientPost(url, body string) ([]byte, error) { client := new(http.Client) b := strings.NewReader(body) req, err := http.NewRequest("POST", url, b) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) } data, err := io.ReadAll(resp.Body) return data, err } func clientPostWithAuth(url, body, username, password string) ([]byte, error) { client := new(http.Client) b := strings.NewReader(body) req, err := http.NewRequest("POST", url, b) if err != nil { return nil, err } req.SetBasicAuth(username, password) resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) } data, err := io.ReadAll(resp.Body) return data, err } func clientGetWithAuth(url, username, password string) ([]byte, error) { client := new(http.Client) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.SetBasicAuth(username, password) resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode) } data, err := io.ReadAll(resp.Body) return data, err } func assertEqual(t *testing.T, a any, b any, message string) { if a == b { return } message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b) t.Fatal(message) } ================================================ FILE: server/ui/api/v1/index.html ================================================ Mailpit API v1 documentation
Mailpit API v1 documentation
Mailpit ================================================ FILE: server/ui/api/v1/swagger.json ================================================ { "consumes": [ "application/json" ], "produces": [ "application/json" ], "schemes": [ "http" ], "swagger": "2.0", "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": { "/api/v1/chaos": { "get": { "description": "Returns the current Chaos triggers configuration.\nThis API route will return an error if Chaos is not enabled at runtime.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "testing" ], "summary": "Get Chaos triggers", "operationId": "getChaos", "responses": { "200": { "$ref": "#/responses/ChaosResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "put": { "description": "Set the Chaos triggers configuration and return the updated values.\nThis API route will return an error if Chaos is not enabled at runtime.\n\nIf any triggers are omitted from the request, then those are reset to their\ndefault values with a 0% probability (ie: disabled).\nSetting a blank `{}` will reset all triggers to their default values.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "testing" ], "summary": "Set Chaos triggers", "operationId": "setChaosParams", "parameters": [ { "name": "Body", "in": "body", "schema": { "$ref": "#/definitions/ChaosTriggers" } } ], "responses": { "200": { "$ref": "#/responses/ChaosResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/info": { "get": { "description": "Returns basic runtime information, message totals and latest release version.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "application" ], "summary": "Get application information", "operationId": "AppInformation", "responses": { "200": { "$ref": "#/responses/AppInfoResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/message/{ID}": { "get": { "description": "Returns the summary of a message, marking the message as read.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Get message summary", "operationId": "GetMessageParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "description": "Message", "schema": { "$ref": "#/definitions/Message" } }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/headers": { "get": { "description": "Returns the message headers as an array. Note that header keys are returned alphabetically.\n\nThe ID can be set to `latest` to return the latest message headers.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Get message headers", "operationId": "GetHeadersParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "description": "MessageHeadersResponse", "schema": { "$ref": "#/definitions/MessageHeadersResponse" } }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/html-check": { "get": { "description": "Returns the summary of the message HTML checker.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "other" ], "summary": "HTML check", "operationId": "HTMLCheckParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "description": "HTMLCheckResponse", "schema": { "$ref": "#/definitions/HTMLCheckResponse" } }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/link-check": { "get": { "description": "Returns the summary of the message Link checker.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "other" ], "summary": "Link check", "operationId": "LinkCheckParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", "default": "false", "x-go-name": "Follow", "description": "Follow redirects", "name": "follow", "in": "query" } ], "responses": { "200": { "description": "LinkCheckResponse", "schema": { "$ref": "#/definitions/LinkCheckResponse" } }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/part/{PartID}": { "get": { "description": "This will return the attachment part using the appropriate Content-Type.\n\nThe ID can be set to `latest` to reference the latest message.", "produces": [ "application/*", "image/*", "text/*" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Get message attachment", "operationId": "AttachmentParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", "description": "Attachment part ID", "name": "PartID", "in": "path", "required": true } ], "responses": { "200": { "$ref": "#/responses/BinaryResponse" }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/part/{PartID}/thumb": { "get": { "description": "This will return a cropped 180x120 JPEG thumbnail of an image attachment.\nIf the image is smaller than 180x120 then the image is padded. If the attachment is not an image then a blank image is returned.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "image/jpeg" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Get an attachment image thumbnail", "operationId": "ThumbnailParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", "description": "Attachment part ID", "name": "PartID", "in": "path", "required": true } ], "responses": { "200": { "$ref": "#/responses/BinaryResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/message/{ID}/raw": { "get": { "description": "Returns the full email source as plain text.\n\nThe ID can be set to `latest` to return the latest message source.", "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Get message source", "operationId": "DownloadRawParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "$ref": "#/responses/TextResponse" }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/release": { "post": { "description": "Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured.\n\nThe ID can be set to `latest` to reference the latest message.", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Release message", "operationId": "ReleaseMessageParams", "parameters": [ { "type": "string", "description": "Message database ID", "name": "ID", "in": "path", "required": true }, { "name": "Body", "in": "body", "schema": { "type": "object", "required": [ "To" ], "properties": { "To": { "description": "Array of email addresses to relay the message to", "type": "array", "items": { "type": "string" }, "example": [ "user1@example.com", "user2@example.com" ] } } } } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/message/{ID}/sa-check": { "get": { "description": "Returns the SpamAssassin summary (if enabled) of the message.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "other" ], "summary": "SpamAssassin check", "operationId": "SpamAssassinCheckParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "description": "SpamAssassinResponse", "schema": { "$ref": "#/definitions/SpamAssassinResponse" } }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/api/v1/messages": { "get": { "description": "Returns messages from the mailbox ordered from newest to oldest.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "messages" ], "summary": "List messages", "operationId": "GetMessagesParams", "parameters": [ { "type": "integer", "format": "int64", "default": 0, "x-go-name": "Start", "description": "Pagination offset", "name": "start", "in": "query" }, { "type": "integer", "format": "int64", "default": 50, "x-go-name": "Limit", "description": "Limit number of results", "name": "limit", "in": "query" } ], "responses": { "200": { "$ref": "#/responses/MessagesSummaryResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "put": { "description": "You can optionally provide an array of IDs or a search string.\nIf neither IDs nor search is provided then all mailbox messages are updated.", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "messages" ], "summary": "Set read status", "operationId": "SetReadStatusParams", "parameters": [ { "name": "Body", "in": "body", "schema": { "type": "object", "properties": { "IDs": { "description": "Optional array of message database IDs", "type": "array", "default": [], "items": { "type": "string" }, "example": [ "4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6" ] }, "Read": { "description": "Read status", "type": "boolean", "default": false, "example": true }, "Search": { "description": "Optional messages matching a search", "type": "string", "example": "tag:backups" } } } }, { "type": "string", "x-go-name": "TZ", "description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", "name": "tz", "in": "query" } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "delete": { "description": "Delete individual or all messages. If no IDs are provided then all messages are deleted.", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "messages" ], "summary": "Delete messages", "operationId": "DeleteMessagesParams", "parameters": [ { "description": "Delete request", "name": "Body", "in": "body", "schema": { "type": "object", "properties": { "IDs": { "description": "Array of message database IDs", "type": "array", "items": { "type": "string" }, "example": [ "4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6" ] } } } } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/search": { "get": { "description": "Returns messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/), sorted by received date (descending).", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "messages" ], "summary": "Search messages", "operationId": "SearchParams", "parameters": [ { "type": "string", "x-go-name": "Query", "description": "Search query", "name": "query", "in": "query", "required": true }, { "type": "string", "default": "0", "x-go-name": "Start", "description": "Pagination offset", "name": "start", "in": "query" }, { "type": "string", "default": "50", "x-go-name": "Limit", "description": "Limit results", "name": "limit", "in": "query" }, { "type": "string", "x-go-name": "TZ", "description": "Optional [timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", "name": "tz", "in": "query" } ], "responses": { "200": { "$ref": "#/responses/MessagesSummaryResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "delete": { "description": "Delete all messages matching [a search](https://mailpit.axllent.org/docs/usage/search-filters/).", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "messages" ], "summary": "Delete messages by search", "operationId": "DeleteSearchParams", "parameters": [ { "type": "string", "x-go-name": "Query", "description": "Search query", "name": "query", "in": "query", "required": true }, { "type": "string", "x-go-name": "TZ", "description": "[Timezone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used only for `before:` \u0026 `after:` searches (eg: \"Pacific/Auckland\").", "name": "tz", "in": "query" } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/send": { "post": { "description": "Send a message via the HTTP API.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "message" ], "summary": "Send a message", "operationId": "SendMessageParams", "parameters": [ { "name": "Body", "in": "body", "schema": { "type": "object", "required": [ "From" ], "properties": { "Attachments": { "description": "Attachments", "type": "array", "items": { "type": "object", "required": [ "Content", "Filename" ], "properties": { "Content": { "description": "Base64-encoded string of the file content", "type": "string", "example": "iVBORw0KGgoAAAANSUhEUgAAAEEAAAA8CAMAAAAOlSdoAAAACXBIWXMAAAHrAAAB6wGM2bZBAAAAS1BMVEVHcEwRfnUkZ2gAt4UsSF8At4UtSV4At4YsSV4At4YsSV8At4YsSV4At4YsSV4sSV4At4YsSV4At4YtSV4At4YsSV4At4YtSV8At4YsUWYNAAAAGHRSTlMAAwoXGiktRE5dbnd7kpOlr7zJ0d3h8PD8PCSRAAACWUlEQVR42pXT4ZaqIBSG4W9rhqQYocG+/ys9Y0Z0Br+x3j8zaxUPewFh65K+7yrIMeIY4MT3wPfEJCidKXEMnLaVkxDiELiMz4WEOAZSFghxBIypCOlKiAMgXfIqTnBgSm8CIQ6BImxEUxEckClVQiHGj4Ba4AQHikAIClwTE9KtIghAhUJwoLkmLnCiAHJLRKgIMsEtVUKbBUIwoAg2C4QgQBE6l4VCnApBgSKYLLApCnCa0+96AEMW2BQcmC+Pr3nfp7o5Exy49gIADcIqUELGfeA+bp93LmAJp8QJoEcN3C7NY3sbVANixMyI0nku20/n5/ZRf3KI2k6JEDWQtxcbdGuAqu3TAXG+/799Oyyas1B1MnMiA+XyxHp9q0PUKGPiRAau1fZbLRZV09wZcT8/gHk8QQAxXn8VgaDqcUmU6O/r28nbVwXAqca2mRNtPAF5+zoP2MeN9Fy4NgC6RfcbgE7XITBRYTtOE3U3C2DVff7pk+PkUxgAbvtnPXJaD6DxulMLwOhPS/M3MQkgg1ZFrIXnmfaZoOfpKiFgzeZD/WuKqQEGrfJYkyWf6vlG3xUgTuscnkNkQsb599q124kdpMUjCa/XARHs1gZymVtGt3wLkiFv8rUgTxitYCex5EVGec0Y9VmoDTFBSQte2TfXGXlf7hbdaUM9Sk7fisEN9qfBBTK+FZcvM9fQSdkl2vj4W2oX/bRogO3XasiNH7R0eW7fgRM834ImTg+Lg6BEnx4vz81rhr+MYPBBQg1v8GndEOrthxaCTxNAOut8WKLGZQl+MPz88Q9tAO/hVuSeqQAAAABJRU5ErkJggg==" }, "ContentID": { "description": "Optional Content-ID (`cid`) for attachment.\nIf this field is set then the file is attached inline.", "type": "string", "example": "mailpit-logo" }, "ContentType": { "description": "Optional Content Type for the the attachment.\nIf this field is not set (or empty) then the content type is automatically detected.", "type": "string", "example": "image/png" }, "Filename": { "description": "Filename", "type": "string", "example": "mailpit.png" } } } }, "Bcc": { "description": "Bcc recipients email addresses only", "type": "array", "items": { "type": "string" }, "example": [ "jack@example.com" ] }, "Cc": { "description": "Cc recipients", "type": "array", "items": { "type": "object", "required": [ "Email" ], "properties": { "Email": { "description": "Email address", "type": "string", "example": "manager@example.com" }, "Name": { "description": "Optional name", "type": "string", "example": "Manager" } } } }, "From": { "description": "\"From\" recipient", "type": "object", "required": [ "Email" ], "properties": { "Email": { "description": "Email address", "type": "string", "example": "john@example.com" }, "Name": { "description": "Optional name", "type": "string", "example": "John Doe" } } }, "HTML": { "description": "Message body (HTML)", "type": "string", "example": "\u003cdiv style=\"text-align:center\"\u003e\u003cp style=\"font-family: arial; font-size: 24px;\"\u003eMailpit is \u003cb\u003eawesome\u003c/b\u003e!\u003c/p\u003e\u003cp\u003e\u003cimg src=\"cid:mailpit-logo\" /\u003e\u003c/p\u003e\u003c/div\u003e" }, "Headers": { "description": "Optional headers in {\"key\":\"value\"} format", "type": "object", "additionalProperties": { "type": "string" }, "example": { "X-IP": "1.2.3.4" } }, "ReplyTo": { "description": "Optional Reply-To recipients", "type": "array", "items": { "type": "object", "required": [ "Email" ], "properties": { "Email": { "description": "Email address", "type": "string", "example": "secretary@example.com" }, "Name": { "description": "Optional name", "type": "string", "example": "Secretary" } } } }, "Subject": { "description": "Subject", "type": "string", "example": "Mailpit message via the HTTP API" }, "Tags": { "description": "Mailpit tags", "type": "array", "items": { "type": "string" }, "example": [ "Tag 1", "Tag 2" ] }, "Text": { "description": "Message body (text)", "type": "string", "example": "Mailpit is awesome!" }, "To": { "description": "\"To\" recipients", "type": "array", "items": { "type": "object", "required": [ "Email" ], "properties": { "Email": { "description": "Email address", "type": "string", "example": "jane@example.com" }, "Name": { "description": "Optional name", "type": "string", "example": "Jane Doe" } } } } } } } ], "responses": { "200": { "$ref": "#/responses/SendMessageResponse" }, "400": { "$ref": "#/responses/JSONErrorResponse" } } } }, "/api/v1/tags": { "get": { "description": "Returns a JSON array of all unique message tags.", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "tags" ], "summary": "Get all current tags", "operationId": "GetAllTags", "responses": { "200": { "$ref": "#/responses/ArrayResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "put": { "description": "This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array.", "consumes": [ "application/json" ], "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "tags" ], "summary": "Set message tags", "operationId": "SetTagsParams", "parameters": [ { "name": "Body", "in": "body", "schema": { "type": "object", "required": [ "Tags", "IDs" ], "properties": { "IDs": { "description": "Array of message database IDs", "type": "array", "items": { "type": "string" }, "example": [ "4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6" ] }, "Tags": { "description": "Array of tag names to set", "type": "array", "items": { "type": "string" }, "example": [ "Tag 1", "Tag 2" ] } } } } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/tags/{Tag}": { "put": { "description": "Renames an existing tag.", "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "tags" ], "summary": "Rename a tag", "operationId": "RenameTagParams", "parameters": [ { "type": "string", "description": "The url-encoded tag name to rename", "name": "Tag", "in": "path", "required": true }, { "name": "Body", "in": "body", "schema": { "type": "object", "required": [ "Name" ], "properties": { "Name": { "description": "New name", "type": "string", "example": "New name" } } } } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } }, "delete": { "description": "Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag.", "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "tags" ], "summary": "Delete a tag", "operationId": "DeleteTagParams", "parameters": [ { "type": "string", "description": "The url-encoded tag name to delete", "name": "Tag", "in": "path", "required": true } ], "responses": { "200": { "$ref": "#/responses/OKResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/api/v1/webui": { "get": { "description": "Returns configuration settings for the web UI.\nIntended for web UI only!", "produces": [ "application/json" ], "schemes": [ "http", "https" ], "tags": [ "application" ], "summary": "Get web UI configuration", "operationId": "WebUIConfigurationResponse", "responses": { "200": { "$ref": "#/responses/WebUIConfigurationResponse" }, "400": { "$ref": "#/responses/ErrorResponse" } } } }, "/view/{ID}.html": { "get": { "description": "Renders just the message's HTML part which can be used for UI integration testing.\nAttached inline images are modified to link to the API provided they exist.\nNote that is the message does not contain a HTML part then an 404 error is returned.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "text/html" ], "schemes": [ "http", "https" ], "tags": [ "testing" ], "summary": "Render message HTML part", "operationId": "GetMessageHTMLParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true }, { "type": "string", "x-go-name": "Embed", "description": "If this is route is to be embedded in an iframe, set embed to `1` in the URL to add `target=\"_blank\"` and `rel=\"noreferrer noopener\"` to all links.\n\nIn addition, a small script will be added to the end of the document to post (postMessage()) the height of the document back to the parent window for optional iframe height resizing.\n\nNote that this will also *transform* the message into a full HTML document (if it isn't already), so this option is useful for viewing but not programmatic testing.", "name": "embed", "in": "query" } ], "responses": { "200": { "$ref": "#/responses/HTMLResponse" }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } }, "/view/{ID}.txt": { "get": { "description": "Renders just the message's text part which can be used for UI integration testing.\n\nThe ID can be set to `latest` to return the latest message.", "produces": [ "text/plain" ], "schemes": [ "http", "https" ], "tags": [ "testing" ], "summary": "Render message text part", "operationId": "GetMessageTextParams", "parameters": [ { "type": "string", "description": "Message database ID or \"latest\"", "name": "ID", "in": "path", "required": true } ], "responses": { "200": { "$ref": "#/responses/TextResponse" }, "400": { "$ref": "#/responses/ErrorResponse" }, "404": { "$ref": "#/responses/NotFoundResponse" } } } } }, "definitions": { "Address": { "description": "An address such as \"Barry Gibbs \u003cbg@example.com\u003e\" is represented\nas Address{Name: \"Barry Gibbs\", Address: \"bg@example.com\"}.", "type": "object", "title": "Address represents a single mail address.", "properties": { "Address": { "type": "string" }, "Name": { "type": "string" } }, "x-go-package": "net/mail" }, "AppInformation": { "description": "AppInformation struct", "type": "object", "properties": { "Database": { "description": "Database path", "type": "string" }, "DatabaseSize": { "description": "Database size in bytes", "type": "integer", "format": "uint64" }, "LatestVersion": { "description": "Latest Mailpit version", "type": "string" }, "Messages": { "description": "Total number of messages in the database", "type": "integer", "format": "uint64" }, "RuntimeStats": { "description": "Runtime statistics", "type": "object", "properties": { "Memory": { "description": "Current memory usage in bytes", "type": "integer", "format": "uint64" }, "MessagesDeleted": { "description": "Database runtime messages deleted", "type": "integer", "format": "uint64" }, "SMTPAccepted": { "description": "Accepted runtime SMTP messages", "type": "integer", "format": "uint64" }, "SMTPAcceptedSize": { "description": "Total runtime accepted messages size in bytes", "type": "integer", "format": "uint64" }, "SMTPIgnored": { "description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)", "type": "integer", "format": "uint64" }, "SMTPRejected": { "description": "Rejected runtime SMTP messages", "type": "integer", "format": "uint64" }, "Uptime": { "description": "Mailpit server uptime in seconds", "type": "integer", "format": "uint64" } } }, "Tags": { "description": "Tags and message totals per tag", "type": "object", "additionalProperties": { "type": "integer", "format": "int64" } }, "Unread": { "description": "Total number of messages in the database", "type": "integer", "format": "uint64" }, "Version": { "description": "Current Mailpit version", "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/stats" }, "Attachment": { "description": "Attachment struct for inline images and attachments", "type": "object", "properties": { "Checksums": { "description": "File checksums", "type": "object", "properties": { "MD5": { "description": "MD5 checksum hash of file", "type": "string" }, "SHA1": { "description": "SHA1 checksum hash of file", "type": "string" }, "SHA256": { "description": "SHA256 checksum hash of file", "type": "string" } } }, "ContentID": { "description": "Content ID", "type": "string" }, "ContentType": { "description": "Content type", "type": "string" }, "FileName": { "description": "File name", "type": "string" }, "PartID": { "description": "Attachment part ID", "type": "string" }, "Size": { "description": "Size in bytes", "type": "integer", "format": "uint64" } }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, "ChaosTrigger": { "description": "Trigger for Chaos", "type": "object", "required": [ "ErrorCode", "Probability" ], "properties": { "ErrorCode": { "description": "SMTP error code to return. The value must range from 400 to 599.", "type": "integer", "format": "int64", "example": 451 }, "Probability": { "description": "Probability (chance) of triggering the error. The value must range from 0 to 100.", "type": "integer", "format": "int64", "example": 5 } }, "x-go-name": "Trigger", "x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos" }, "ChaosTriggers": { "description": "Triggers for the Chaos configuration", "type": "object", "properties": { "Authentication": { "$ref": "#/definitions/ChaosTrigger" }, "Recipient": { "$ref": "#/definitions/ChaosTrigger" }, "Sender": { "$ref": "#/definitions/ChaosTrigger" } }, "x-go-name": "Triggers", "x-go-package": "github.com/axllent/mailpit/internal/smtpd/chaos" }, "HTMLCheckResponse": { "description": "Response represents the HTML check response struct", "type": "object", "properties": { "Platforms": { "description": "All platforms tested, mainly for the web UI", "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } }, "Total": { "$ref": "#/definitions/HTMLCheckTotal" }, "Warnings": { "description": "List of warnings from tests", "type": "array", "items": { "$ref": "#/definitions/HTMLCheckWarning" } } }, "x-go-name": "Response", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, "HTMLCheckResult": { "description": "Result struct", "type": "object", "properties": { "Family": { "description": "Family eg: Outlook, Mozilla Thunderbird", "type": "string" }, "Name": { "description": "Friendly name of result, combining family, platform \u0026 version", "type": "string" }, "NoteNumber": { "description": "Note number for partially supported if applicable", "type": "string" }, "Platform": { "description": "Platform eg: ios, android, windows", "type": "string" }, "Support": { "description": "Support [yes, no, partial]", "type": "string" }, "Version": { "description": "Family version eg: 4.7.1, 2019-10, 10.3", "type": "string" } }, "x-go-name": "Result", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, "HTMLCheckScore": { "description": "Score struct", "type": "object", "properties": { "Found": { "description": "Number of matches in the document", "type": "integer", "format": "int64" }, "Partial": { "description": "Total percentage partially supported", "type": "number", "format": "float" }, "Supported": { "description": "Total percentage supported", "type": "number", "format": "float" }, "Unsupported": { "description": "Total percentage unsupported", "type": "number", "format": "float" } }, "x-go-name": "Score", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, "HTMLCheckTotal": { "description": "Total weighted result for all scores", "type": "object", "properties": { "Nodes": { "description": "Total number of HTML nodes detected in message", "type": "integer", "format": "int64" }, "Partial": { "description": "Overall percentage partially supported", "type": "number", "format": "float" }, "Supported": { "description": "Overall percentage supported", "type": "number", "format": "float" }, "Tests": { "description": "Total number of tests done", "type": "integer", "format": "int64" }, "Unsupported": { "description": "Overall percentage unsupported", "type": "number", "format": "float" } }, "x-go-name": "Total", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, "HTMLCheckWarning": { "description": "Warning represents a failed test", "type": "object", "properties": { "Category": { "description": "Category [css, html]", "type": "string" }, "Description": { "description": "Description", "type": "string" }, "Keywords": { "description": "Keywords", "type": "string" }, "NotesByNumber": { "description": "Notes based on results", "type": "object", "additionalProperties": { "type": "string" } }, "Results": { "description": "Test results", "type": "array", "items": { "$ref": "#/definitions/HTMLCheckResult" } }, "Score": { "$ref": "#/definitions/HTMLCheckScore" }, "Slug": { "description": "Slug identifier", "type": "string" }, "Tags": { "description": "Tags", "type": "array", "items": { "type": "string" } }, "Title": { "description": "Friendly title", "type": "string" }, "URL": { "description": "URL to caniemail.com", "type": "string" } }, "x-go-name": "Warning", "x-go-package": "github.com/axllent/mailpit/internal/htmlcheck" }, "Link": { "description": "Link struct", "type": "object", "properties": { "Status": { "description": "HTTP status definition", "type": "string" }, "StatusCode": { "description": "HTTP status code", "type": "integer", "format": "int64" }, "URL": { "description": "Link URL", "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/linkcheck" }, "LinkCheckResponse": { "description": "Response represents the Link check response", "type": "object", "properties": { "Errors": { "description": "Total number of errors", "type": "integer", "format": "int64" }, "Links": { "description": "Tested links", "type": "array", "items": { "$ref": "#/definitions/Link" } } }, "x-go-name": "Response", "x-go-package": "github.com/axllent/mailpit/internal/linkcheck" }, "ListUnsubscribe": { "description": "ListUnsubscribe contains a summary of List-Unsubscribe \u0026 List-Unsubscribe-Post headers\nincluding validation of the link structure", "type": "object", "properties": { "Errors": { "description": "Validation errors (if any)", "type": "string" }, "Header": { "description": "List-Unsubscribe header value", "type": "string" }, "HeaderPost": { "description": "List-Unsubscribe-Post value (if set)", "type": "string" }, "Links": { "description": "Detected links, maximum one email and one HTTP(S) link", "type": "array", "items": { "type": "string" } } }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, "Message": { "description": "Message data excluding physical attachments", "type": "object", "properties": { "Attachments": { "description": "Message attachments", "type": "array", "items": { "$ref": "#/definitions/Attachment" } }, "Bcc": { "description": "Bcc addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Cc": { "description": "Cc addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Date": { "description": "Message RFC3339Nano date \u0026 time (if set), else date \u0026 time received\n([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)", "type": "string", "format": "date-time" }, "From": { "$ref": "#/definitions/Address" }, "HTML": { "description": "Message body HTML", "type": "string" }, "ID": { "description": "Database ID", "type": "string" }, "Inline": { "description": "Inline message attachments", "type": "array", "items": { "$ref": "#/definitions/Attachment" } }, "ListUnsubscribe": { "$ref": "#/definitions/ListUnsubscribe" }, "MessageID": { "description": "Message ID", "type": "string" }, "ReplyTo": { "description": "ReplyTo addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "ReturnPath": { "description": "Return-Path", "type": "string" }, "Size": { "description": "Message size in bytes", "type": "integer", "format": "uint64" }, "Subject": { "description": "Message subject", "type": "string" }, "Tags": { "description": "Message tags", "type": "array", "items": { "type": "string" } }, "Text": { "description": "Message body text", "type": "string" }, "To": { "description": "To addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Username": { "description": "Username used for authentication (if provided) with the SMTP or Send API", "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, "MessageHeadersResponse": { "description": "Message headers", "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } }, "x-go-name": "messageHeadersResponse", "x-go-package": "github.com/axllent/mailpit/server/apiv1" }, "MessageSummary": { "description": "MessageSummary struct for frontend messages", "type": "object", "properties": { "Attachments": { "description": "Whether the message has any attachments", "type": "integer", "format": "int64" }, "Bcc": { "description": "Bcc addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Cc": { "description": "Cc addresses", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Created": { "description": "Received RFC3339Nano date \u0026 time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds)", "type": "string", "format": "date-time" }, "From": { "$ref": "#/definitions/Address" }, "ID": { "description": "Database ID", "type": "string" }, "MessageID": { "description": "Message ID", "type": "string" }, "Read": { "description": "Read status", "type": "boolean" }, "ReplyTo": { "description": "Reply-To address", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Size": { "description": "Message size in bytes (total)", "type": "integer", "format": "uint64" }, "Snippet": { "description": "Message snippet includes up to 250 characters", "type": "string" }, "Subject": { "description": "Email subject", "type": "string" }, "Tags": { "description": "Message tags", "type": "array", "items": { "type": "string" } }, "To": { "description": "To address", "type": "array", "items": { "$ref": "#/definitions/Address" } }, "Username": { "description": "Username used for authentication (if provided) with the SMTP or Send API", "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/storage" }, "MessagesSummary": { "description": "MessagesSummary is a summary of a list of messages", "type": "object", "properties": { "messages": { "description": "Messages summary\nin: body", "type": "array", "items": { "$ref": "#/definitions/MessageSummary" }, "x-go-name": "Messages" }, "messages_count": { "description": "Total number of messages matching current query", "type": "integer", "format": "uint64", "x-go-name": "MessagesCount" }, "messages_unread": { "description": "Total number of unread messages matching current query", "type": "integer", "format": "uint64", "x-go-name": "MessagesUnreadCount" }, "start": { "description": "Pagination offset", "type": "integer", "format": "int64", "x-go-name": "Start" }, "tags": { "description": "All current tags", "type": "array", "items": { "type": "string" }, "x-go-name": "Tags" }, "total": { "description": "Total number of messages in mailbox", "type": "integer", "format": "uint64", "x-go-name": "Total" }, "unread": { "description": "Total number of unread messages in mailbox", "type": "integer", "format": "uint64", "x-go-name": "Unread" } }, "x-go-package": "github.com/axllent/mailpit/server/apiv1" }, "Rule": { "description": "Rule struct", "type": "object", "properties": { "Description": { "description": "SpamAssassin rule description", "type": "string" }, "Name": { "description": "SpamAssassin rule name", "type": "string" }, "Score": { "description": "Spam rule score", "type": "number", "format": "double" } }, "x-go-package": "github.com/axllent/mailpit/internal/spamassassin" }, "SpamAssassinResponse": { "description": "Result is a SpamAssassin result", "type": "object", "properties": { "Error": { "description": "If populated will return an error string", "type": "string" }, "IsSpam": { "description": "Whether the message is spam or not", "type": "boolean" }, "Rules": { "description": "Spam rules triggered", "type": "array", "items": { "$ref": "#/definitions/Rule" } }, "Score": { "description": "Total spam score based on triggered rules", "type": "number", "format": "double" } }, "x-go-name": "Result", "x-go-package": "github.com/axllent/mailpit/internal/spamassassin" } }, "responses": { "AppInfoResponse": { "description": "Application information", "schema": { "$ref": "#/definitions/AppInformation" } }, "ArrayResponse": { "description": "Plain JSON array response", "schema": { "type": "array", "items": { "type": "string" } } }, "BinaryResponse": { "description": "Binary data response which inherits the attachment's content type.", "schema": { "type": "string" } }, "ChaosResponse": { "description": "Response for the Chaos triggers configuration", "schema": { "$ref": "#/definitions/ChaosTriggers" } }, "ErrorResponse": { "description": "Server error will return with a 400 status code\nwith the error message in the body", "schema": { "type": "string" } }, "HTMLResponse": { "description": "HTML response", "schema": { "type": "string" } }, "JSONErrorResponse": { "description": "JSON error response", "schema": { "type": "object", "properties": { "Error": { "description": "Error message", "type": "string", "example": "invalid format" } } } }, "MessagesSummaryResponse": { "description": "Summary of messages", "schema": { "$ref": "#/definitions/MessagesSummary" } }, "NotFoundResponse": { "description": "Not found error will return a 404 status code", "schema": { "type": "string" } }, "OKResponse": { "description": "Plain text \"ok\" response", "schema": { "type": "string" } }, "SendMessageResponse": { "description": "Confirmation message for HTTP send API", "schema": { "type": "object", "properties": { "ID": { "description": "Database ID", "type": "string", "example": "iAfZVVe2UQfNSG5BAjgYwa" } } } }, "TextResponse": { "description": "Plain text response", "schema": { "type": "string" } }, "WebUIConfigurationResponse": { "description": "Web UI configuration response", "schema": { "type": "object", "properties": { "ChaosEnabled": { "description": "Whether Chaos support is enabled at runtime", "type": "boolean" }, "DuplicatesIgnored": { "description": "Whether messages with duplicate IDs are ignored", "type": "boolean" }, "HideDeleteAllButton": { "description": "Whether the delete button should be hidden", "type": "boolean" }, "Label": { "description": "Optional label to identify this Mailpit instance", "type": "string" }, "MessageRelay": { "description": "Message Relay information", "type": "object", "properties": { "AllowedRecipients": { "description": "Only allow relaying to these recipients (regex)", "type": "string" }, "BlockedRecipients": { "description": "Block relaying to these recipients (regex)", "type": "string" }, "Enabled": { "description": "Whether message relaying (release) is enabled", "type": "boolean" }, "OverrideFrom": { "description": "Overrides the \"From\" address for all relayed messages", "type": "string" }, "PreserveMessageIDs": { "description": "Preserve the original Message-IDs when relaying messages", "type": "boolean" }, "ReturnPath": { "description": "Enforced Return-Path (if set) for relay bounces", "type": "string" }, "SMTPServer": { "description": "The configured SMTP server address", "type": "string" } } }, "SpamAssassin": { "description": "Whether SpamAssassin is enabled", "type": "boolean" } } } } } } ================================================ FILE: server/ui-src/App.vue ================================================ ================================================ FILE: server/ui-src/app.js ================================================ import App from "./App.vue"; import router from "./router"; import { createApp } from "vue"; import mitt from "mitt"; import "./assets/styles.scss"; import "bootstrap-icons/font/bootstrap-icons.scss"; import "bootstrap"; import "vue-css-donut-chart/src/styles/main.css"; const app = createApp(App); // Global event bus used to subscribe to websocket events // such as message deletes, updates & truncation. const eventBus = mitt(); app.provide("eventBus", eventBus); app.use(router); app.mount("#app"); ================================================ FILE: server/ui-src/assets/_bootstrap.scss ================================================ @import "_bootstrap_variables"; // 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"; // 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"; // Helpers @import "bootstrap/scss/helpers"; // Utilities @import "bootstrap/scss/utilities/api"; // scss-docs-end import-stack ================================================ FILE: server/ui-src/assets/_bootstrap_variables.scss ================================================ // 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; $secondary: #495057; $list-group-disabled-color: #adb5bd; $enable-negative-margins: true; $body-color-dark: #e7eaed; $offcanvas-border-width: 0; $body-color: #080808; $btn-disabled-opacity: 0.4; ================================================ FILE: server/ui-src/assets/styles.scss ================================================ @import "./bootstrap"; [v-cloak] { display: none !important; } .navbar { z-index: 99; .navbar-brand { color: #2d4a5d; transition: all 0.2s; img { width: 40px; } @include media-breakpoint-down(md) { padding: 0; img { width: 35px; } } } } .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; } } } .link { @extend a; cursor: pointer; } .loader { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.4); z-index: 1500; } // dark mode adjustments @include color-mode(dark) { .loader { background: rgba(0, 0, 0, 0.4); } .token.tag, .token.property { color: #ee6969; } .btn-outline-secondary { color: #9c9c9c; &:hover { color: $body-color-dark; } } } .text-spaces-nowrap { white-space: pre; } .text-spaces { white-space: pre-wrap; } #nav-plain-text .text-view, #nav-source { white-space: pre; 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; } .messageHeaders { margin: 15px 0 0; th { padding-right: 1.5rem; font-weight: normal; vertical-align: top; min-width: 120px; } td { vertical-align: top; } } #nav-html { @include media-breakpoint-up(md) { 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%); } } } .messageHeaders { th { vertical-align: top; } } #message-page, #MessageList { .list-group-item.message:first-child { border-top: 0; } .message:not(.active) { b { color: $list-group-color; } &.read { color: $text-muted; > div { opacity: 0.5; } b { color: $list-group-color; } } &.selected { background: var(--bs-primary-bg-subtle); } } } body.blur { .privacy { filter: blur(3px); } } .card.attachment { color: $gray-800; .icon { position: absolute; top: 18px; left: 0; right: 0; font-size: 3.5rem; text-align: center; color: $gray-300; } .card-body { position: absolute; top: 0; right: 0; bottom: 0; left: 0; overflow: hidden; opacity: 0; } .card-footer { background: $gray-300; .bi { font-size: 1.3em; margin-left: -10px; } } &:hover { .card-body { opacity: 1; background: $gray-300; } } } .form-select.tag-selector { display: none; } // dropdown doesn't always appear in correct position inside modals .dropdown.form-select { position: relative !important; } .message { &.read { > div { opacity: 0.7; } } } #message-view { .form-control.dropdown { padding: 0; border: 0; input { font-size: 0.875em; } div { cursor: text; // html5-tags } } } .dropdown-menu.checks { .dropdown-item { min-width: 190px; } } // bootstrap5-tags .tags-badge { display: flex; } #DownloadBtn { @include media-breakpoint-down(sm) { position: static; .dropdown-menu { left: 0; right: 0; } } } // HighlightJS for HTML rendering @import "highlight.js/styles/github.css"; @include color-mode(dark) { @import "highlight.js/scss/github-dark"; .hljs { background: transparent; } } code[class*="language-"], pre[class*="language-"] { font-size: 0.85em; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; word-wrap: normal; line-height: 1.5; -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; } pre[class*="language-"] { position: relative; overflow: visible; } pre[class*="language-"] > code { position: relative; z-index: 1; } code[class*="language-"] { max-height: inherit; height: inherit; padding: 0 1em; display: block; overflow: auto; } :not(pre) > code[class*="language-"], pre[class*="language-"] { // background-color: #fdfdfd; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; margin-bottom: 1em; } :not(pre) > code[class*="language-"] { position: relative; padding: 0.2em; border-radius: 0.3em; color: #c92c2c; border: 1px solid rgba(0, 0, 0, 0.1); display: inline; white-space: normal; } @media screen and (max-width: 767px) { pre[class*="language-"]::after, pre[class*="language-"]::before { bottom: 14px; box-shadow: none; } } ================================================ FILE: server/ui-src/components/AjaxLoader.vue ================================================ ================================================ FILE: server/ui-src/components/AppAbout.vue ================================================ ================================================ FILE: server/ui-src/components/AppBadge.vue ================================================ ================================================ FILE: server/ui-src/components/AppFavicon.vue ================================================ ================================================ FILE: server/ui-src/components/AppNotifications.vue ================================================ ================================================ FILE: server/ui-src/components/AppSettings.vue ================================================ ================================================ FILE: server/ui-src/components/EditTags.vue ================================================ ================================================ FILE: server/ui-src/components/ListMessages.vue ================================================ ================================================ FILE: server/ui-src/components/NavMailbox.vue ================================================ ================================================ FILE: server/ui-src/components/NavPagination.vue ================================================ ================================================ FILE: server/ui-src/components/NavSearch.vue ================================================ ================================================ FILE: server/ui-src/components/NavSelected.vue ================================================ ================================================ FILE: server/ui-src/components/NavTags.vue ================================================ ================================================ FILE: server/ui-src/components/SearchForm.vue ================================================ ================================================ FILE: server/ui-src/components/message/HTMLCheck.vue ================================================ ================================================ FILE: server/ui-src/components/message/LinkCheck.vue ================================================ ================================================ FILE: server/ui-src/components/message/MessageAttachments.vue ================================================ ================================================ FILE: server/ui-src/components/message/MessageHeaders.vue ================================================ ================================================ FILE: server/ui-src/components/message/MessageItem.vue ================================================ ================================================ FILE: server/ui-src/components/message/MessageRelease.vue ================================================ ================================================ FILE: server/ui-src/components/message/MessageScreenshot.vue ================================================ ================================================ FILE: server/ui-src/components/message/SpamAssassin.vue ================================================ ================================================ FILE: server/ui-src/docs.js ================================================ import "rapidoc"; ================================================ FILE: server/ui-src/mixins/CommonMixins.js ================================================ import axios from "axios"; import dayjs from "dayjs"; import ColorHash from "color-hash"; import { Modal, Offcanvas } from "bootstrap"; import { limitOptions } from "../stores/pagination"; // BootstrapElement is used to return a fake Bootstrap element // if the ID returns nothing to prevent errors. class BootstrapElement { hide() {} show() {} } // 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] }); /* Common mixin functions used in apps */ export default { data() { return { loading: 0, tagColorCache: {}, copiedText: {}, // used for clipboard copy feedback }; }, computed: { copyToClipboardSupported() { return !!navigator.clipboard; }, }, methods: { resolve(u) { return this.$router.resolve(u).href; }, searchURI(s) { return this.resolve("/search") + "?q=" + encodeURIComponent(s); }, getFileSize(bytes) { if (bytes === 0) { return "0B"; } const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; }, formatNumber(nr) { return new Intl.NumberFormat().format(nr); }, messageDate(d) { return dayjs(d).format("ddd, D MMM YYYY, h:mm a"); }, secondsToRelative(d) { return dayjs().subtract(d, "seconds").fromNow(); }, tagEncodeURI(tag) { if (tag.match(/ /)) { tag = `"${tag}"`; } return encodeURIComponent(`tag:${tag}`); }, getSearch() { if (!window.location.search) { return false; } const urlParams = new URLSearchParams(window.location.search); const q = urlParams.get("q")?.trim(); if (!q) { return false; } return q; }, getPaginationParams() { if (!window.location.search) { return null; } const urlParams = new URLSearchParams(window.location.search); const start = parseInt(urlParams.get("start")?.trim(), 10); const limit = parseInt(urlParams.get("limit")?.trim(), 10); return { start: Number.isInteger(start) && start >= 0 ? start : null, limit: limitOptions.includes(limit) ? limit : null, }; }, // generic modal get/set function modal(id) { const e = document.getElementById(id); if (e) { return Modal.getOrCreateInstance(e); } // in case there are open/close actions return new BootstrapElement(); }, // close mobile navigation hideNav() { const e = document.getElementById("offcanvas"); if (e) { Offcanvas.getOrCreateInstance(e).hide(); } }, /** * Axios GET request * * @params string url * @params array array parameters Object/array * @params function callback function * @params function error callback function */ get(url, values, callback, errorCallback, hideLoader) { if (!hideLoader) { this.loading++; } axios .get(url, { params: values }) .then(callback) .catch((err) => { if (typeof errorCallback === "function") { return errorCallback(err); } this.handleError(err); }) .then(() => { // always executed if (!hideLoader && this.loading > 0) { this.loading--; } }); }, /** * Axios POST request * * @params string url * @params array object/array values * @params function callback function */ post(url, data, callback) { this.loading++; axios .post(url, data) .then(callback) .catch(this.handleError) .then(() => { // always executed if (this.loading > 0) { this.loading--; } }); }, /** * Axios DELETE request (REST only) * * @params string url * @params array object/array values * @params function callback function */ delete(url, data, callback) { this.loading++; axios .delete(url, { data }) .then(callback) .catch(this.handleError) .then(() => { // always executed if (this.loading > 0) { this.loading--; } }); }, /** * Axios PUT request (REST only) * * @params string url * @params array object/array values * @params function callback function */ put(url, data, callback) { this.loading++; axios .put(url, data) .then(callback) .catch(this.handleError) .then(() => { // always executed if (this.loading > 0) { this.loading--; } }); }, // Ajax error message handleError(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) { alert(error.response.data.Error); } else { alert(error.response.data); } } else if (error.request) { // The request was made but no response was received alert("Error sending data to the server. Please try again."); } else { // Something happened in setting up the request that triggered an Error alert(error.message); } }, allAttachments(message) { const a = []; for (const i in message.Attachments) { message.Attachments[i].ContentDisposition = "Attachment"; a.push(message.Attachments[i]); } for (const i in message.OtherParts) { message.OtherParts[i].ContentDisposition = "Other"; a.push(message.OtherParts[i]); } for (const i in message.Inline) { message.Inline[i].ContentDisposition = "Inline"; a.push(message.Inline[i]); } return a.length ? a : false; }, isImage(a) { return a.ContentType.match(/^image\//); }, attachmentIcon(a) { const ext = a.FileName.split(".").pop().toLowerCase(); if (a.ContentType.match(/^image\//)) { return "bi-file-image-fill"; } if (a.ContentType.match(/\/pdf$/) || ext === "pdf") { return "bi-file-pdf-fill"; } if (["doc", "docx", "odt", "rtf"].includes(ext)) { return "bi-file-word-fill"; } if (["xls", "xlsx", "ods"].includes(ext)) { return "bi-file-spreadsheet-fill"; } if (["ppt", "pptx", "key", "ppt", "odp"].includes(ext)) { return "bi-file-slides-fill"; } if (["zip", "tar", "rar", "bz2", "gz", "xz"].includes(ext)) { return "bi-file-zip-fill"; } if (["ics"].includes(ext)) { return "bi-calendar-event"; } if (a.ContentType.match(/^audio\//)) { return "bi-file-music-fill"; } if (a.ContentType.match(/^video\//)) { return "bi-file-play-fill"; } if (a.ContentType.match(/\/calendar$/)) { return "bi-file-check-fill"; } if (a.ContentType.match(/^text\//) || ["txt", "sh", "log"].includes(ext)) { return "bi-file-text-fill"; } 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(s) { if (this.tagColorCache[s] !== undefined) { return this.tagColorCache[s]; } this.tagColorCache[s] = colorHash.hex(s); return this.tagColorCache[s]; }, // Copy to clipboard functionality copyToClipboard(text) { navigator.clipboard.writeText(text).then( () => { this.copiedText[text] = true; setTimeout(() => { delete this.copiedText[text]; }, 2000); }, () => { // failure alert("Failed to copy to clipboard"); }, ); }, }, }; ================================================ FILE: server/ui-src/mixins/MessagesMixins.js ================================================ import CommonMixins from "./CommonMixins.js"; import { mailbox } from "../stores/mailbox.js"; import { pagination } from "../stores/pagination.js"; export default { mixins: [CommonMixins], data() { return { apiURI: false, pagination, mailbox, }; }, watch: { "mailbox.refresh"(v) { if (v) { // trigger a refresh this.loadMessages(); } mailbox.refresh = false; }, }, methods: { reloadMailbox() { pagination.start = 0; this.loadMessages(); }, loadMessages() { if (!this.apiURI) { alert("apiURL not set!"); return; } // auto-pagination changes the URL but should not fetch new messages // when viewing page > 0 and new messages are received (inbox only) if (!mailbox.autoPaginating) { mailbox.autoPaginating = true; // reset return; } const params = {}; mailbox.selected = []; params.limit = pagination.limit; if (pagination.start > 0) { params.start = pagination.start; } this.get(this.apiURI, params, (response) => { mailbox.total = response.data.total; // all messages mailbox.unread = response.data.unread; // all unread messages mailbox.tags = response.data.tags; // all tags mailbox.messages = response.data.messages; // current messages mailbox.count = response.data.messages_count; // total results for this mailbox/search mailbox.messages_unread = response.data.messages_unread; // total unread results for this mailbox/search // ensure the pagination remains consistent pagination.start = response.data.start; if (response.data.count === 0 && response.data.start > 0) { pagination.start = 0; return this.loadMessages(); } if (mailbox.lastMessage) { window.setTimeout(() => { const m = document.getElementById(mailbox.lastMessage); if (m) { m.focus(); // m.scrollIntoView({ behavior: 'smooth', block: 'center' }) m.scrollIntoView({ block: "center" }); } else { const mp = document.getElementById("message-page"); if (mp) { mp.scrollTop = 0; } } mailbox.lastMessage = false; }, 50); } else if (!window.scrollInPlace) { const mp = document.getElementById("message-page"); if (mp) { mp.scrollTop = 0; } } window.scrollInPlace = false; }); }, }, }; ================================================ FILE: server/ui-src/router/index.js ================================================ import { createRouter, createWebHistory } from "vue-router"; import MailboxView from "../views/MailboxView.vue"; import MessageView from "../views/MessageView.vue"; import NotFoundView from "../views/NotFoundView.vue"; import SearchView from "../views/SearchView.vue"; const d = document.getElementById("app"); let webroot = "/"; if (d) { webroot = d.dataset.webroot; } // paths are relative to webroot const router = createRouter({ history: createWebHistory(webroot), routes: [ { path: "/", component: MailboxView, }, { path: "/search", component: SearchView, }, { path: "/view/:id", component: MessageView, }, { path: "/:pathMatch(.*)*", name: "NotFound", component: NotFoundView, }, ], }); export default router; ================================================ FILE: server/ui-src/stores/mailbox.js ================================================ // State Management import { reactive, watch } from "vue"; // Parse and validate a string[] from localStorage, returning [] on any invalid value. const storageToStringArray = (key) => { try { const raw = localStorage.getItem(key); if (!raw) return []; const parsed = JSON.parse(raw); if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) { return parsed; } } catch { // ignore malformed JSON } return []; }; // global mailbox info export const mailbox = reactive({ total: 0, // total number of messages in database unread: 0, // total unread messages in database count: 0, // total in mailbox or search messages: [], // current messages tags: [], // all tags selected: [], // currently selected connected: false, // websocket connection searching: false, // current search, false for none refresh: false, // to listen from MessagesMixin autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination notificationsSupported: false, // browser supports notifications notificationsEnabled: false, // user has enabled notifications skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read" appInfo: {}, // application information uiConfig: {}, // configuration for UI lastMessage: false, // return scrolling defaultReleaseAddresses: storageToStringArray("defaultReleaseAddresses"), // default release addresses for released messages // settings showTagColors: !localStorage.getItem("hideTagColors"), showHTMLCheck: !localStorage.getItem("hideHTMLCheck"), showLinkCheck: !localStorage.getItem("hideLinkCheck"), showSpamCheck: !localStorage.getItem("hideSpamCheck"), timeZone: localStorage.getItem("timeZone") ? localStorage.getItem("timeZone") : Intl.DateTimeFormat().resolvedOptions().timeZone, showAttachmentDetails: localStorage.getItem("showAttachmentDetails"), // show attachment details }); watch( () => mailbox.count, () => { mailbox.selected = []; }, ); watch( () => mailbox.showTagColors, (v) => { if (v) { localStorage.removeItem("hideTagColors"); } else { localStorage.setItem("hideTagColors", "1"); } }, ); watch( () => mailbox.showHTMLCheck, (v) => { if (v) { localStorage.removeItem("hideHTMLCheck"); } else { localStorage.setItem("hideHTMLCheck", "1"); } }, ); watch( () => mailbox.showLinkCheck, (v) => { if (v) { localStorage.removeItem("hideLinkCheck"); } else { localStorage.setItem("hideLinkCheck", "1"); } }, ); watch( () => mailbox.showSpamCheck, (v) => { if (v) { localStorage.removeItem("hideSpamCheck"); } else { localStorage.setItem("hideSpamCheck", "1"); } }, ); watch( () => mailbox.defaultReleaseAddresses, (v) => { if (v.length) { localStorage.setItem("defaultReleaseAddresses", JSON.stringify(v)); } else { localStorage.removeItem("defaultReleaseAddresses"); } }, ); watch( () => mailbox.timeZone, (v) => { if (v === Intl.DateTimeFormat().resolvedOptions().timeZone) { localStorage.removeItem("timeZone"); } else { localStorage.setItem("timeZone", v); } }, ); watch( () => mailbox.showAttachmentDetails, (v) => { if (v) { localStorage.setItem("showAttachmentDetails", "1"); } else { localStorage.removeItem("showAttachmentDetails"); } }, ); ================================================ FILE: server/ui-src/stores/pagination.js ================================================ import { reactive } from "vue"; export const pagination = reactive({ start: 0, // pagination offset limit: 50, // per page defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit total: 0, // total results of current view / filter count: 0, // number of messages currently displayed }); export const limitOptions = [25, 50, 100, 200]; ================================================ FILE: server/ui-src/views/MailboxView.vue ================================================ ================================================ FILE: server/ui-src/views/MessageView.vue ================================================ ================================================ FILE: server/ui-src/views/NotFoundView.vue ================================================ ================================================ FILE: server/ui-src/views/SearchView.vue ================================================ ================================================ FILE: server/webhook/webhook.go ================================================ // Package webhook will optionally call a preconfigured endpoint package webhook import ( "bytes" "encoding/json" "net/http" "sync" "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" "golang.org/x/time/rate" ) var ( // RateLimit is the minimum number of seconds between requests. // Additional requests within this period will be ignored until // the time has elapsed. RateLimit = 1 // Delay is the number of seconds to wait before sending each webhook request // This can allow for other processing to complete before the webhook is triggered. Delay = 0 rl rate.Sometimes once sync.Once ) // Send will post the MessageSummary to a webhook (if configured) func Send(msg any) { if config.WebhookURL == "" { return } once.Do(func() { if RateLimit > 0 { rl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second} } else { // allow every request rl = rate.Sometimes{Every: 1} } }) rl.Do(func() { go func() { // apply delay if configured if Delay > 0 { time.Sleep(time.Duration(Delay) * time.Second) } b, err := json.Marshal(msg) if err != nil { logger.Log().Errorf("[webhook] invalid data: %s", err.Error()) return } req, err := http.NewRequest("POST", config.WebhookURL, bytes.NewBuffer(b)) if err != nil { logger.Log().Errorf("[webhook] error: %s", err.Error()) return } req.Header.Set("User-Agent", "Mailpit/"+config.Version) req.Header.Set("Content-Type", "application/json") if config.Label != "" { req.Header.Set("Mailpit-Label", config.Label) } client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { logger.Log().Errorf("[webhook] error sending data: %s", err.Error()) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { logger.Log().Warnf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode) return } }() }) } ================================================ FILE: server/websockets/client.go ================================================ // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package websockets import ( "net/http" "time" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" "github.com/gorilla/websocket" ) const ( // Time allowed to write a message to the peer. writeWait = 10 * time.Second // Time allowed to read the next pong message from the peer. pongWait = 60 * time.Second // Send pings to peer with this period. Must be less than pongWait. pingPeriod = (pongWait * 9) / 10 ) var ( newline = []byte{'\n'} // MessageHub global MessageHub *Hub ) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, EnableCompression: true, CheckOrigin: func(_ *http.Request) bool { // origin is checked via server.go's CORS settings return true }, } // Client is a middleman between the websocket connection and the hub. type Client struct { hub *Hub // The websocket connection. conn *websocket.Conn // Buffered channel of outbound messages. send chan []byte } // ReadPump is used here solely to monitor the connection, not to actually receive messages. func (c *Client) readPump() { defer func() { c.hub.unregister <- c }() for { _, _, err := c.conn.NextReader() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { logger.Log().Errorf("[websocket] error: %v", err.Error()) } break } } } // WritePump pumps messages from the hub to the websocket connection. // // A goroutine running writePump is started for each connection. The // application ensures that there is at most one writer to a connection by // executing all writes from this goroutine. func (c *Client) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.hub.unregister <- c }() for { select { case message, ok := <-c.send: _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { // The hub closed the channel. _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) return } w, err := c.conn.NextWriter(websocket.TextMessage) if err != nil { return } _, _ = w.Write(message) // Add queued chat messages to the current websocket message. n := len(c.send) for range n { _, _ = w.Write(newline) _, _ = w.Write(<-c.send) } if err := w.Close(); err != nil { return } case <-ticker.C: _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) _ = c.conn.WriteMessage(websocket.PingMessage, []byte{}) } } } // ServeWs handles websocket requests from the peer. func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { if auth.UICredentials != nil { user, pass, ok := r.BasicAuth() if !ok { basicAuthResponse(w) return } if !auth.UICredentials.Match(user, pass) { basicAuthResponse(w) return } } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { logger.Log().Errorf("[websocket] %s", err.Error()) return } client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} client.hub.register <- client // Allow collection of memory referenced by the caller by doing all work in new goroutines. go client.readPump() go client.writePump() } // BasicAuthResponse returns an basic auth response to the browser func basicAuthResponse(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("Unauthorized.\n")) } ================================================ FILE: server/websockets/hub.go ================================================ // Package websockets is used to broadcast messages to connected clients package websockets import ( "encoding/json" "time" "github.com/axllent/mailpit/internal/logger" ) // Hub maintains the set of active clients and broadcasts messages to the // clients. type Hub struct { // Registered clients. Clients map[*Client]bool // Inbound messages from the clients. Broadcast chan []byte // Register requests from the clients. register chan *Client // Unregister requests from clients. unregister chan *Client } // WebsocketNotification struct for responses type WebsocketNotification struct { Type string Data any } // NewHub returns a new hub configuration func NewHub() *Hub { return &Hub{ Broadcast: make(chan []byte), register: make(chan *Client), unregister: make(chan *Client), Clients: make(map[*Client]bool), } } // Run runs the listener func (h *Hub) Run() { for { select { case client := <-h.register: if _, ok := h.Clients[client]; !ok { logger.Log().Debugf("[websocket] client %s connected", client.conn.RemoteAddr().String()) h.Clients[client] = true } case client := <-h.unregister: if _, ok := h.Clients[client]; ok { logger.Log().Debugf("[websocket] client %s disconnected", client.conn.RemoteAddr().String()) delete(h.Clients, client) close(client.send) } case message := <-h.Broadcast: for client := range h.Clients { select { case client.send <- message: default: close(client.send) delete(h.Clients, client) } } } } } // Broadcast will spawn a broadcast message to all connected clients func Broadcast(t string, msg any) { if MessageHub == nil || len(MessageHub.Clients) == 0 { return } w := WebsocketNotification{} w.Type = t w.Data = msg b, err := json.Marshal(w) if err != nil { logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err.Error()) return } // add a very small delay to prevent broadcasts from being interpreted // as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series) time.Sleep(time.Millisecond) go func() { MessageHub.Broadcast <- b }() } // BroadCastClientError is a wrapper to broadcast client errors to the web UI func BroadCastClientError(severity, errorType, ip, message string) { msg := struct { Level string Type string IP string Message string }{ severity, errorType, ip, message, } Broadcast("error", msg) }