├── .dockerignore ├── .github ├── FUNDING.yml ├── cliff.toml ├── dependabot.yml └── workflows │ ├── build-docker-edge.yml │ ├── build-docker.yml │ ├── close-stale-issues.yml │ ├── codeql-analysis.yml │ ├── release-build.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.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 ├── 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 ├── smtpd │ ├── chaos │ │ └── chaos.go │ ├── forward.go │ ├── main.go │ ├── relay.go │ ├── smtpd.go │ └── smtpd_test.go ├── spamassassin │ ├── postmark │ │ └── postmark.go │ ├── spamassassin.go │ └── spamc │ │ └── spamc.go ├── stats │ └── stats.go ├── storage │ ├── cron.go │ ├── database.go │ ├── messages.go │ ├── messages_test.go │ ├── notifications.go │ ├── reindex.go │ ├── schemas.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 │ ├── search.go │ ├── search_test.go │ ├── settings.go │ ├── structs.go │ ├── tagfilters.go │ ├── tags.go │ ├── tags_test.go │ ├── testdata │ │ ├── mime-attachment.eml │ │ ├── plain-text.eml │ │ └── tags.eml │ ├── testing.go │ └── utils.go ├── tools │ ├── argsparser.go │ ├── fs.go │ ├── headers.go │ ├── html.go │ ├── listunsubscribeparser.go │ ├── snippets.go │ ├── tags.go │ ├── tools_test.go │ ├── unixsocket.go │ └── utils.go └── updater │ ├── targz.go │ ├── unzip.go │ └── updater.go ├── main.go ├── package-lock.json ├── 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 ├── swagger.go ├── tags.go ├── testing.go └── thumbnails.go ├── embed.go ├── handlers ├── k8healthz.go ├── k8sready.go ├── messages.go └── proxy.go ├── server.go ├── server_test.go ├── ui-src ├── App.vue ├── app.js ├── assets │ ├── _bootstrap.scss │ ├── _bootstrap_variables.scss │ └── styles.scss ├── components │ ├── AboutMailpit.vue │ ├── AjaxLoader.vue │ ├── AppBadge.vue │ ├── EditTags.vue │ ├── Favicon.vue │ ├── ListMessages.vue │ ├── NavMailbox.vue │ ├── NavSearch.vue │ ├── NavSelected.vue │ ├── NavTags.vue │ ├── Notifications.vue │ ├── Pagination.vue │ ├── SearchForm.vue │ ├── Settings.vue │ └── message │ │ ├── Attachments.vue │ │ ├── HTMLCheck.vue │ │ ├── Headers.vue │ │ ├── LinkCheck.vue │ │ ├── Message.vue │ │ ├── Release.vue │ │ ├── Screenshot.vue │ │ └── SpamAssassin.vue ├── docs.js ├── mixins │ ├── CommonMixins.js │ └── MessagesMixins.js ├── router │ └── index.js ├── screenshot.png ├── stores │ ├── mailbox.js │ └── pagination.js └── views │ ├── MailboxView.vue │ ├── MessageView.vue │ ├── NotFoundView.vue │ └── SearchView.vue ├── ui ├── api │ └── v1 │ │ ├── index.html │ │ └── swagger.json ├── favicon.ico ├── favicon.svg ├── mailpit.svg └── notification.png ├── webhook └── webhook.go └── websockets ├── client.go └── hub.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /mailpit 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [axllent] 4 | -------------------------------------------------------------------------------- /.github/cliff.toml: -------------------------------------------------------------------------------- 1 | ## https://git-cliff.org/ 2 | [changelog] 3 | body = """ 4 | {% if version %}\ 5 | \n## [{{ version }}] 6 | {% else %}\ 7 | \n## Unreleased 8 | {% endif %}\ 9 | {% for group, commits in commits | group_by(attribute="group") %} 10 | ### {{ group | striptags | trim | upper_first }}\ 11 | {% for commit in commits %} 12 | - {{ commit.message | upper_first }}\ 13 | {% endfor %} 14 | {% endfor %}\n 15 | """ 16 | footer = "" 17 | header = "# Changelog\n\nNotable changes to Mailpit will be documented in this file." 18 | postprocessors = [ 19 | {pattern = "reponse", replace = "response"}, 20 | {pattern = "messsage", replace = "message"}, 21 | {pattern = '(?i) go modules', replace = " Go dependencies"}, 22 | {pattern = '(?i) node modules', replace = " node dependencies"}, 23 | {pattern = '#([0-9]+)', replace = "[#$1](https://github.com/axllent/mailpit/issues/$1)"}, 24 | ] 25 | trim = true 26 | 27 | [git] 28 | # HTML comments added for grouping order, stripped on generation 29 | commit_parsers = [ 30 | {body = ".*security", group = "Security"}, 31 | {message = "(?i)^feat", group = "Feature"}, 32 | {message = "(?i)^chore", group = "Chore"}, 33 | {message = "(?i)^libs", group = "Chore"}, 34 | {message = "(?i)^ui", group = "Chore"}, 35 | {message = "(?i)^api", group = "API"}, 36 | {message = "(?i)^fix", group = "Fix"}, 37 | {message = "(?i)^doc", group = "Documentation", default_scope = "unscoped"}, 38 | {message = "(?i)^swagger", group = "Documentation", default_scope = "unscoped"}, 39 | {message = "(?i)^test", group = "Test"}, 40 | ] 41 | 42 | # Exclude commits that are not matched by any commit parser. 43 | # filter_commits = true 44 | # Order releases topologically instead of chronologically. 45 | # topo_order = true 46 | # Order of commits in each group/release within the changelog. 47 | # Allowed values: newest, oldest 48 | sort_commits = "oldest" 49 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "monthly" 16 | - package-ecosystem: "docker" 17 | directory: "/" # Location of package manifests 18 | schedule: 19 | interval: "monthly" 20 | - package-ecosystem: "npm" 21 | directory: "/" 22 | schedule: 23 | interval: "monthly" 24 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-edge.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ develop ] 4 | 5 | name: Build docker edge images 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v4 12 | 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v3 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Log into Docker Hub 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 24 | 25 | - name: Log into GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ github.token }} 31 | 32 | - uses: benjlevesque/short-sha@v3.0 33 | id: short-sha 34 | 35 | - name: Build and push 36 | uses: docker/build-push-action@v6 37 | with: 38 | context: . 39 | platforms: linux/386,linux/amd64,linux/arm64 40 | build-args: | 41 | "VERSION=edge-${{ steps.short-sha.outputs.sha }}" 42 | push: true 43 | tags: | 44 | axllent/mailpit:edge 45 | ghcr.io/${{ github.repository }}:edge 46 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | name: Build docker images 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v4 12 | 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v3 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Log into Docker Hub 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 24 | 25 | - name: Log into GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ github.token }} 31 | 32 | - name: Parse semver 33 | id: semver_parser 34 | uses: booxmedialtd/ws-action-parse-semver@v1.4.7 35 | with: 36 | input_string: '${{ github.ref_name }}' 37 | version_extractor_regex: 'v(.*)$' 38 | 39 | - name: Build and push 40 | uses: docker/build-push-action@v6 41 | with: 42 | context: . 43 | platforms: linux/386,linux/amd64,linux/arm64 44 | build-args: | 45 | "VERSION=${{ github.ref_name }}" 46 | push: true 47 | tags: | 48 | axllent/mailpit:latest 49 | axllent/mailpit:${{ github.ref_name }} 50 | axllent/mailpit:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }} 51 | ghcr.io/${{ github.repository }}:${{ github.ref_name }} 52 | ghcr.io/${{ github.repository }}:v${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }} 53 | ghcr.io/${{ github.repository }}:latest 54 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9.1.0 14 | with: 15 | days-before-issue-stale: 7 16 | days-before-issue-close: 3 17 | exempt-issue-labels: "enhancement,bug,awaiting feedback" 18 | stale-issue-label: "stale" 19 | stale-issue-message: "This issue has been marked as stale because it has been open for 7 days with no activity." 20 | close-issue-message: "This issue was closed because there has been no activity since being marked as stale." 21 | days-before-pr-stale: -1 22 | days-before-pr-close: -1 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "develop" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "develop" ] 20 | schedule: 21 | - cron: '34 23 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go', 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # 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 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | name: Build & release 6 | jobs: 7 | releases-matrix: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | goos: [linux, windows, darwin] 13 | goarch: ["386", amd64, arm, arm64] 14 | exclude: 15 | - goarch: "386" 16 | goos: darwin 17 | - goarch: "386" 18 | goos: windows 19 | - goarch: arm 20 | goos: darwin 21 | - goarch: arm 22 | goos: windows 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | # build the assets 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: 22 30 | cache: 'npm' 31 | - run: echo "Building assets for ${{ github.ref_name }}" 32 | - run: npm install 33 | - run: npm run package 34 | 35 | # build the binaries 36 | - uses: wangyoucao577/go-release-action@v1 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | goos: ${{ matrix.goos }} 40 | goarch: ${{ matrix.goarch }} 41 | binary_name: "mailpit" 42 | pre_command: export CGO_ENABLED=0 43 | asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }} 44 | extra_files: LICENSE README.md 45 | md5sum: false 46 | overwrite: true 47 | retry: 5 48 | ldflags: -w -X "github.com/axllent/mailpit/config.Version=${{ github.ref_name }}" 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | branches: [ develop, 'feature/**' ] 5 | push: 6 | branches: [ develop, 'feature/**' ] 7 | jobs: 8 | test: 9 | strategy: 10 | matrix: 11 | go-version: ['1.23'] 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | cache: false 19 | - uses: actions/checkout@v4 20 | - name: Run Go tests 21 | uses: actions/cache@v4 22 | with: 23 | path: | 24 | ~/.cache/go-build 25 | ~/go 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | - run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v 30 | - run: go test -p 1 ./internal/storage ./internal/html2text -bench=. 31 | 32 | # build the assets 33 | - name: Build web UI 34 | if: startsWith(matrix.os, 'ubuntu') == true 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 22 38 | cache: 'npm' 39 | - if: startsWith(matrix.os, 'ubuntu') == true 40 | run: npm install 41 | - if: startsWith(matrix.os, 'ubuntu') == true 42 | run: npm run package 43 | 44 | # validate the swagger file 45 | - name: Validate OpenAPI definition 46 | if: startsWith(matrix.os, 'ubuntu') == true 47 | uses: swaggerexpert/swagger-editor-validate@v1 48 | with: 49 | definition-file: server/ui/api/v1/swagger.json 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /send 3 | /sendmail/sendmail 4 | /server/ui/dist 5 | /Makefile 6 | /mailpit* 7 | /.idea 8 | *.old 9 | *.db 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | 3 | ARG VERSION=dev 4 | 5 | COPY . /app 6 | 7 | WORKDIR /app 8 | 9 | RUN apk upgrade && apk add git npm && \ 10 | npm install && npm run package && \ 11 | CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${VERSION}" -o /mailpit 12 | 13 | FROM alpine:latest 14 | 15 | LABEL org.opencontainers.image.title="Mailpit" \ 16 | org.opencontainers.image.description="An email and SMTP testing tool with API for developers" \ 17 | org.opencontainers.image.source="https://github.com/axllent/mailpit" \ 18 | org.opencontainers.image.url="https://mailpit.axllent.org" \ 19 | org.opencontainers.image.documentation="https://mailpit.axllent.org/docs/" \ 20 | org.opencontainers.image.licenses="MIT" 21 | 22 | COPY --from=builder /mailpit /mailpit 23 | 24 | RUN apk upgrade --no-cache && apk add --no-cache tzdata 25 | 26 | EXPOSE 1025/tcp 1110/tcp 8025/tcp 27 | 28 | HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"] 29 | 30 | ENTRYPOINT ["/mailpit"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022-Now() Ralph Slooten 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security vulnerabilities 2 | 3 | Your efforts to responsibly disclose your findings are appreciated. 4 | 5 | ** **Please do _not_ report security vulnerabilities through public GitHub issues.** ** 6 | 7 | If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so 8 | your findings can be investigated, and if confirmed, fixed and released in a timely manner. 9 | 10 | Your report should include: 11 | 12 | - Mailpit version 13 | - A vulnerability description 14 | - Reproduction steps (if applicable) 15 | - Any other details you think are likely to be important 16 | 17 | You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process. 18 | 19 | With your consent, your contributions will be publicly acknowledged. 20 | -------------------------------------------------------------------------------- /cmd/dump.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/axllent/mailpit/config" 5 | "github.com/axllent/mailpit/internal/dump" 6 | "github.com/axllent/mailpit/internal/logger" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // dumpCmd represents the dump command 11 | var dumpCmd = &cobra.Command{ 12 | Use: "dump ", 13 | Short: "Dump all messages from a database to a directory", 14 | Long: `Dump all messages stored in Mailpit into a local directory as individual files. 15 | 16 | The database can either be the database file (eg: --database /var/lib/mailpit/mailpit.db) or a 17 | URL of a running Mailpit instance (eg: --http http://127.0.0.1/). If dumping over HTTP, the URL 18 | should be the base URL of your running Mailpit instance, not the link to the API itself.`, 19 | Args: cobra.ExactArgs(1), 20 | Run: func(cmd *cobra.Command, args []string) { 21 | if err := dump.Sync(args[0]); err != nil { 22 | logger.Log().Fatal(err) 23 | } 24 | }, 25 | } 26 | 27 | func init() { 28 | rootCmd.AddCommand(dumpCmd) 29 | 30 | dumpCmd.Flags().SortFlags = false 31 | 32 | dumpCmd.Flags().StringVar(&config.Database, "database", config.Database, "Dump messages directly from a database file") 33 | dumpCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data (optional)") 34 | dumpCmd.Flags().StringVar(&dump.URL, "http", dump.URL, "Dump messages via HTTP API (base URL of running Mailpit instance)") 35 | dumpCmd.Flags().BoolVarP(&logger.VerboseLogging, "verbose", "v", logger.VerboseLogging, "Verbose logging") 36 | } 37 | -------------------------------------------------------------------------------- /cmd/readyz.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "path" 9 | "strings" 10 | "time" 11 | 12 | "github.com/axllent/mailpit/config" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | useHTTPS bool 18 | ) 19 | 20 | // readyzCmd represents the healthcheck command 21 | var readyzCmd = &cobra.Command{ 22 | Use: "readyz", 23 | Short: "Run a healthcheck to test if Mailpit is running", 24 | Long: `This command connects to the /readyz endpoint of a running Mailpit server 25 | and exits with a status of 0 if the connection is successful, else with a 26 | status 1 if unhealthy. 27 | 28 | If running within Docker, it should automatically detect environment 29 | settings to determine the HTTP bind interface & port. 30 | `, 31 | Run: func(cmd *cobra.Command, args []string) { 32 | webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/" 33 | proto := "http" 34 | if useHTTPS { 35 | proto = "https" 36 | } 37 | 38 | uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot) 39 | 40 | conf := &http.Transport{ 41 | IdleConnTimeout: time.Second * 5, 42 | ExpectContinueTimeout: time.Second * 5, 43 | TLSHandshakeTimeout: time.Second * 5, 44 | // do not verify TLS in case this instance is using HTTPS 45 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec 46 | } 47 | client := &http.Client{Transport: conf} 48 | 49 | res, err := client.Get(uri) 50 | if err != nil || res.StatusCode != 200 { 51 | os.Exit(1) 52 | } 53 | }, 54 | } 55 | 56 | func init() { 57 | rootCmd.AddCommand(readyzCmd) 58 | 59 | if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 { 60 | config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR") 61 | } 62 | 63 | if len(os.Getenv("MP_WEBROOT")) > 0 { 64 | config.Webroot = os.Getenv("MP_WEBROOT") 65 | } 66 | 67 | config.UITLSCert = os.Getenv("MP_UI_TLS_CERT") 68 | 69 | if config.UITLSCert != "" { 70 | useHTTPS = true 71 | } 72 | 73 | readyzCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "Set the HTTP bind interface & port") 74 | readyzCmd.Flags().StringVar(&config.Webroot, "webroot", config.Webroot, "Set the webroot for web UI & API") 75 | readyzCmd.Flags().BoolVar(&useHTTPS, "https", useHTTPS, "Connect via HTTPS (ignores HTTPS validation)") 76 | } 77 | -------------------------------------------------------------------------------- /cmd/reindex.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/axllent/mailpit/config" 7 | "github.com/axllent/mailpit/internal/logger" 8 | "github.com/axllent/mailpit/internal/storage" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // reindexCmd represents the reindex command 13 | var reindexCmd = &cobra.Command{ 14 | Use: "reindex ", 15 | Short: "Reindex the database", 16 | Long: `This will reindex all messages in the entire database. 17 | 18 | If you have several thousand messages in your mailbox, then it is advised to shut down 19 | Mailpit while you reindex as this process will likely result in database locking issues.`, 20 | Args: cobra.ExactArgs(1), 21 | Run: func(cmd *cobra.Command, args []string) { 22 | config.Database = args[0] 23 | config.MaxMessages = 0 24 | 25 | if err := storage.InitDB(); err != nil { 26 | logger.Log().Error(err) 27 | os.Exit(1) 28 | } 29 | 30 | storage.ReindexAll() 31 | }, 32 | } 33 | 34 | func init() { 35 | rootCmd.AddCommand(reindexCmd) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/sendmail.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | sendmail "github.com/axllent/mailpit/sendmail/cmd" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // sendmailCmd represents the sendmail command 11 | var sendmailCmd = &cobra.Command{ 12 | Use: "sendmail [flags] [recipients]", 13 | Short: "A sendmail command replacement for Mailpit", 14 | Run: func(_ *cobra.Command, _ []string) { 15 | sendmail.Run() 16 | }, 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(sendmailCmd) 21 | var ignored string 22 | 23 | // print out manual help screen 24 | sendmailCmd.SetHelpTemplate(sendmail.HelpTemplate([]string{os.Args[0], "sendmail"})) 25 | 26 | // these are simply repeated for cli consistency as cobra/viper does not allow 27 | // multi-letter single-dash variables (-bs) 28 | sendmailCmd.Flags().StringVarP(&sendmail.FromAddr, "from", "f", sendmail.FromAddr, "SMTP sender") 29 | sendmailCmd.Flags().StringVarP(&sendmail.SMTPAddr, "smtp-addr", "S", sendmail.SMTPAddr, "SMTP server address") 30 | sendmailCmd.Flags().BoolVarP(&sendmail.UseB, "ignored-b", "b", false, "Handle SMTP commands on standard input (use as -bs)") 31 | sendmailCmd.Flags().BoolVarP(&sendmail.UseS, "ignored-s", "s", false, "Handle SMTP commands on standard input (use as -bs)") 32 | sendmailCmd.Flags().BoolP("verbose", "v", false, "Verbose mode (sends debug output to stderr)") 33 | sendmailCmd.Flags().BoolP("ignored-i", "i", false, "Ignored") 34 | sendmailCmd.Flags().BoolP("ignored-o", "o", false, "Ignored") 35 | sendmailCmd.Flags().BoolP("ignored-t", "t", false, "Ignored") 36 | sendmailCmd.Flags().StringVarP(&ignored, "ignored-name", "F", "", "Ignored") 37 | sendmailCmd.Flags().StringVarP(&ignored, "ignored-bits", "B", "", "Ignored") 38 | sendmailCmd.Flags().StringVarP(&ignored, "ignored-errors", "e", "", "Ignored") 39 | } 40 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/axllent/mailpit/config" 9 | "github.com/axllent/mailpit/internal/updater" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // versionCmd represents the version command 14 | var versionCmd = &cobra.Command{ 15 | Use: "version", 16 | Short: "Display the current version & update information", 17 | Long: `Display the current version & update information (if available).`, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | 20 | updater.AllowPrereleases = true 21 | 22 | update, _ := cmd.Flags().GetBool("update") 23 | 24 | if update { 25 | return updateApp() 26 | } 27 | 28 | fmt.Printf("%s %s compiled with %s on %s/%s\n", 29 | os.Args[0], config.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) 30 | 31 | latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName) 32 | if err == nil && updater.GreaterThan(latest, config.Version) { 33 | fmt.Printf( 34 | "\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n", 35 | latest, 36 | os.Args[0], 37 | ) 38 | } 39 | 40 | return nil 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(versionCmd) 46 | 47 | versionCmd.Flags(). 48 | BoolP("update", "u", false, "update to latest version") 49 | } 50 | 51 | func updateApp() error { 52 | rel, err := updater.GithubUpdate(config.Repo, config.RepoBinaryName, config.Version) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | fmt.Printf("Updated %s to version %s\n", os.Args[0], rel) 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /config/tags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/axllent/mailpit/internal/logger" 10 | "github.com/axllent/mailpit/internal/tools" 11 | "github.com/goccy/go-yaml" 12 | ) 13 | 14 | var ( 15 | // TagsDisablePlus disables message tagging using plus-addresses (user+tag@example.com) - set via verifyConfig() 16 | TagsDisablePlus bool 17 | 18 | // TagsDisableXTags disables message tagging via the X-Tags header - set via verifyConfig() 19 | TagsDisableXTags bool 20 | ) 21 | 22 | type yamlTags struct { 23 | Filters []yamlTag `yaml:"filters"` 24 | } 25 | 26 | type yamlTag struct { 27 | Match string `yaml:"match"` 28 | Tags string `yaml:"tags"` 29 | } 30 | 31 | // Load tags from a configuration from a file, if set 32 | func loadTagsFromConfig(c string) error { 33 | if c == "" { 34 | return nil // not set, ignore 35 | } 36 | 37 | c = filepath.Clean(c) 38 | 39 | if !isFile(c) { 40 | return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c) 41 | } 42 | 43 | data, err := os.ReadFile(c) 44 | if err != nil { 45 | return fmt.Errorf("[tags] %s", err.Error()) 46 | } 47 | 48 | conf := yamlTags{} 49 | 50 | if err := yaml.Unmarshal(data, &conf); err != nil { 51 | return err 52 | } 53 | 54 | if conf.Filters == nil { 55 | return fmt.Errorf("[tags] missing tag: array in %s", c) 56 | } 57 | 58 | for _, t := range conf.Filters { 59 | tags := strings.Split(t.Tags, ",") 60 | TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags}) 61 | } 62 | 63 | logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c) 64 | 65 | return nil 66 | } 67 | 68 | func loadTagsFromArgs(c string) error { 69 | if c == "" { 70 | return nil // not set, ignore 71 | } 72 | 73 | args := tools.ArgsParser(c) 74 | 75 | for _, a := range args { 76 | t := strings.Split(a, "=") 77 | if len(t) > 1 { 78 | match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "="))) 79 | tags := strings.Split(t[0], ",") 80 | TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags}) 81 | } else { 82 | return fmt.Errorf("[tag] error parsing tags (%s)", a) 83 | } 84 | } 85 | 86 | logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters")) 87 | 88 | return nil 89 | } 90 | 91 | func parseTagsDisable(s string) error { 92 | s = strings.TrimSpace(s) 93 | if s == "" { 94 | return nil 95 | } 96 | 97 | parts := strings.Split(strings.ToLower(s), ",") 98 | 99 | for _, p := range parts { 100 | switch strings.TrimSpace(p) { 101 | case "x-tags", "xtags": 102 | TagsDisableXTags = true 103 | case "plus-addresses", "plus-addressing": 104 | TagsDisablePlus = true 105 | default: 106 | return fmt.Errorf("[tags] invalid --tags-disable option: %s", p) 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /config/utils.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/axllent/mailpit/internal/tools" 11 | ) 12 | 13 | // IsFile returns whether a file exists and is readable 14 | func isFile(path string) bool { 15 | f, err := os.Open(filepath.Clean(path)) 16 | defer f.Close() 17 | return err == nil 18 | } 19 | 20 | // IsDir returns whether a path is a directory 21 | func isDir(path string) bool { 22 | info, err := os.Stat(path) 23 | if err != nil || os.IsNotExist(err) || !info.IsDir() { 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | 30 | func isValidURL(s string) bool { 31 | u, err := url.ParseRequestURI(s) 32 | if err != nil { 33 | return false 34 | } 35 | 36 | return strings.HasPrefix(u.Scheme, "http") 37 | } 38 | 39 | // DBTenantID converts a tenant ID to a DB-friendly value if set 40 | func DBTenantID(s string) string { 41 | s = tools.Normalize(s) 42 | if s != "" { 43 | re := regexp.MustCompile(`[^a-zA-Z0-9\_]`) 44 | s = re.ReplaceAllString(s, "_") 45 | if !strings.HasSuffix(s, "_") { 46 | s = s + "_" 47 | } 48 | } 49 | 50 | return s 51 | } 52 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | import pluginVue from 'esbuild-plugin-vue-next' 3 | import { sassPlugin } from 'esbuild-sass-plugin' 4 | 5 | const doWatch = process.env.WATCH == 'true' ? true : false; 6 | const doMinify = process.env.MINIFY == 'true' ? true : false; 7 | 8 | const ctx = await esbuild.context( 9 | { 10 | entryPoints: [ 11 | "server/ui-src/app.js", 12 | "server/ui-src/docs.js" 13 | ], 14 | bundle: true, 15 | minify: doMinify, 16 | sourcemap: false, 17 | define: { 18 | '__VUE_OPTIONS_API__': 'true', 19 | '__VUE_PROD_DEVTOOLS__': 'false', 20 | '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false', 21 | }, 22 | outdir: "server/ui/dist/", 23 | plugins: [ 24 | pluginVue(), 25 | sassPlugin({ 26 | silenceDeprecations: ['import'], 27 | quietDeps: true, 28 | }) 29 | ], 30 | loader: { 31 | ".svg": "file", 32 | ".woff": "file", 33 | ".woff2": "file", 34 | }, 35 | logLevel: "info" 36 | } 37 | ) 38 | 39 | if (doWatch) { 40 | await ctx.watch() 41 | } else { 42 | await ctx.rebuild() 43 | ctx.dispose() 44 | } 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/axllent/mailpit 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.2 6 | 7 | // https://github.com/jaytaylor/html2text/issues/67 8 | replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5 9 | 10 | require ( 11 | github.com/PuerkitoBio/goquery v1.10.3 12 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 13 | github.com/axllent/semver v0.0.1 14 | github.com/goccy/go-yaml v1.17.1 15 | github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b 16 | github.com/gorilla/mux v1.8.1 17 | github.com/gorilla/websocket v1.5.3 18 | github.com/jhillyerd/enmime/v2 v2.1.0 19 | github.com/klauspost/compress v1.18.0 20 | github.com/kovidgoyal/imaging v1.6.4 21 | github.com/leporo/sqlf v1.4.0 22 | github.com/lithammer/shortuuid/v4 v4.2.0 23 | github.com/mneis/go-telnet v0.0.0-20221017141824-6f643e477c62 24 | github.com/rqlite/gorqlite v0.0.0-20250128004930-114c7828b55a 25 | github.com/sirupsen/logrus v1.9.3 26 | github.com/spf13/cobra v1.9.1 27 | github.com/spf13/pflag v1.0.6 28 | github.com/tg123/go-htpasswd v1.2.4 29 | github.com/vanng822/go-premailer v1.24.0 30 | golang.org/x/net v0.40.0 31 | golang.org/x/text v0.25.0 32 | golang.org/x/time v0.11.0 33 | modernc.org/sqlite v1.37.1 34 | ) 35 | 36 | require ( 37 | github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect 38 | github.com/andybalholm/cascadia v1.3.3 // indirect 39 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect 40 | github.com/dustin/go-humanize v1.0.1 // indirect 41 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect 42 | github.com/google/uuid v1.6.0 // indirect 43 | github.com/gorilla/css v1.0.1 // indirect 44 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 45 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mattn/go-runewidth v0.0.16 // indirect 48 | github.com/ncruces/go-strftime v0.1.9 // indirect 49 | github.com/olekukonko/tablewriter v1.0.6 // indirect 50 | github.com/pkg/errors v0.9.1 // indirect 51 | github.com/reiver/go-oi v1.0.0 // indirect 52 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 53 | github.com/rivo/uniseg v0.4.7 // indirect 54 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 55 | github.com/valyala/bytebufferpool v1.0.0 // indirect 56 | github.com/vanng822/css v1.0.1 // indirect 57 | golang.org/x/crypto v0.38.0 // indirect 58 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 59 | golang.org/x/image v0.27.0 // indirect 60 | golang.org/x/sys v0.33.0 // indirect 61 | modernc.org/libc v1.65.8 // indirect 62 | modernc.org/mathutil v1.7.1 // indirect 63 | modernc.org/memory v1.11.0 // indirect 64 | ) 65 | -------------------------------------------------------------------------------- /internal/auth/auth.go: -------------------------------------------------------------------------------- 1 | // Package auth handles the web UI and SMTP authentication 2 | package auth 3 | 4 | import ( 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/tg123/go-htpasswd" 9 | ) 10 | 11 | var ( 12 | // UICredentials passwords 13 | UICredentials *htpasswd.File 14 | // SendAPICredentials passwords 15 | SendAPICredentials *htpasswd.File 16 | // SMTPCredentials passwords 17 | SMTPCredentials *htpasswd.File 18 | // POP3Credentials passwords 19 | POP3Credentials *htpasswd.File 20 | ) 21 | 22 | // SetUIAuth will set Basic Auth credentials required for the UI & API 23 | func SetUIAuth(s string) error { 24 | var err error 25 | 26 | credentials := credentialsFromString(s) 27 | if len(credentials) == 0 { 28 | return nil 29 | } 30 | 31 | r := strings.NewReader(strings.Join(credentials, "\n")) 32 | 33 | UICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | return nil 39 | } 40 | 41 | // SetSendAPIAuth will set Send API credentials 42 | func SetSendAPIAuth(s string) error { 43 | var err error 44 | 45 | credentials := credentialsFromString(s) 46 | if len(credentials) == 0 { 47 | return nil 48 | } 49 | 50 | r := strings.NewReader(strings.Join(credentials, "\n")) 51 | 52 | SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | return nil 58 | } 59 | 60 | // SetSMTPAuth will set SMTP credentials 61 | func SetSMTPAuth(s string) error { 62 | var err error 63 | 64 | credentials := credentialsFromString(s) 65 | if len(credentials) == 0 { 66 | return nil 67 | } 68 | 69 | r := strings.NewReader(strings.Join(credentials, "\n")) 70 | 71 | SMTPCredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | // SetPOP3Auth will set POP3 server credentials 80 | func SetPOP3Auth(s string) error { 81 | var err error 82 | 83 | credentials := credentialsFromString(s) 84 | if len(credentials) == 0 { 85 | return nil 86 | } 87 | 88 | r := strings.NewReader(strings.Join(credentials, "\n")) 89 | 90 | POP3Credentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func credentialsFromString(s string) []string { 99 | // split string by any whitespace character 100 | re := regexp.MustCompile(`\s+`) 101 | 102 | words := re.Split(s, -1) 103 | credentials := []string{} 104 | for _, w := range words { 105 | if w != "" { 106 | credentials = append(credentials, w) 107 | } 108 | } 109 | 110 | return credentials 111 | } 112 | -------------------------------------------------------------------------------- /internal/dump/dump.go: -------------------------------------------------------------------------------- 1 | // Package dump is used to export all messages from mailpit into a directory 2 | package dump 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | "os" 10 | "path" 11 | "regexp" 12 | "strings" 13 | 14 | "github.com/axllent/mailpit/config" 15 | "github.com/axllent/mailpit/internal/logger" 16 | "github.com/axllent/mailpit/internal/storage" 17 | "github.com/axllent/mailpit/internal/tools" 18 | "github.com/axllent/mailpit/server/apiv1" 19 | ) 20 | 21 | var ( 22 | linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) 23 | 24 | outDir string 25 | 26 | // Base URL of mailpit instance 27 | base string 28 | 29 | // URL is the base URL of a remove Mailpit instance 30 | URL string 31 | 32 | summary = []storage.MessageSummary{} 33 | ) 34 | 35 | // Sync will sync all messages from the specified database or API to the specified output directory 36 | func Sync(d string) error { 37 | 38 | outDir = path.Clean(d) 39 | 40 | if URL != "" { 41 | if !linkRe.MatchString(URL) { 42 | return errors.New("Invalid URL") 43 | } 44 | 45 | base = strings.TrimRight(URL, "/") + "/" 46 | } 47 | 48 | if base == "" && config.Database == "" { 49 | return errors.New("No database or API URL specified") 50 | } 51 | 52 | if !tools.IsDir(outDir) { 53 | if err := os.MkdirAll(outDir, 0755); /* #nosec */ err != nil { 54 | return err 55 | } 56 | } 57 | 58 | if err := loadIDs(); err != nil { 59 | return err 60 | } 61 | 62 | if err := saveMessages(); err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // LoadIDs will load all message IDs from the specified database or API 70 | func loadIDs() error { 71 | if base != "" { 72 | // remote 73 | logger.Log().Debugf("Fetching messages summary from %s", base) 74 | res, err := http.Get(base + "api/v1/messages?limit=0") 75 | 76 | if err != nil { 77 | return err 78 | } 79 | 80 | body, err := io.ReadAll(res.Body) 81 | 82 | if err != nil { 83 | return err 84 | } 85 | 86 | var data apiv1.MessagesSummary 87 | if err := json.Unmarshal(body, &data); err != nil { 88 | return err 89 | } 90 | 91 | summary = data.Messages 92 | 93 | } else { 94 | // make sure the database isn't pruned while open 95 | config.MaxMessages = 0 96 | 97 | var err error 98 | // local database 99 | if err = storage.InitDB(); err != nil { 100 | return err 101 | } 102 | 103 | logger.Log().Debugf("Fetching messages summary from %s", config.Database) 104 | 105 | summary, err = storage.List(0, 0, 0) 106 | if err != nil { 107 | return err 108 | } 109 | } 110 | 111 | if len(summary) == 0 { 112 | return errors.New("No messages found") 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func saveMessages() error { 119 | for _, m := range summary { 120 | out := path.Join(outDir, m.ID+".eml") 121 | 122 | // skip if message exists 123 | if tools.IsFile(out) { 124 | continue 125 | } 126 | 127 | var b []byte 128 | 129 | if base != "" { 130 | res, err := http.Get(base + "api/v1/message/" + m.ID + "/raw") 131 | 132 | if err != nil { 133 | logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error()) 134 | continue 135 | } 136 | 137 | b, err = io.ReadAll(res.Body) 138 | 139 | if err != nil { 140 | logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error()) 141 | continue 142 | } 143 | } else { 144 | var err error 145 | b, err = storage.GetMessageRaw(m.ID) 146 | if err != nil { 147 | logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error()) 148 | continue 149 | } 150 | } 151 | 152 | if err := os.WriteFile(out, b, 0644); /* #nosec */ err != nil { 153 | logger.Log().Errorf("error writing message %s: %s", m.ID, err.Error()) 154 | continue 155 | } 156 | 157 | _ = os.Chtimes(out, m.Created, m.Created) 158 | 159 | logger.Log().Debugf("Saved message %s to %s", m.ID, out) 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/html2text/html2text.go: -------------------------------------------------------------------------------- 1 | // Package html2text is a simple library to convert HTML to plain text 2 | package html2text 3 | 4 | import ( 5 | "bytes" 6 | "log" 7 | "regexp" 8 | "strings" 9 | "unicode" 10 | 11 | "golang.org/x/net/html" 12 | ) 13 | 14 | var ( 15 | re = regexp.MustCompile(`\s+`) 16 | spaceRe = regexp.MustCompile(`(?mi)<\/(div|p|td|th|h[1-6]|ul|ol|li|address|article|aside|blockquote|dl|dt|footer|header|hr|main|nav|pre|table|thead|tfoot|video)><`) 17 | brRe = regexp.MustCompile(`(?mi)<(br /|br)>`) 18 | imgRe = regexp.MustCompile(`(?mi)<(img)`) 19 | skip = make(map[string]bool) 20 | ) 21 | 22 | func init() { 23 | skip["script"] = true 24 | skip["title"] = true 25 | skip["head"] = true 26 | skip["link"] = true 27 | skip["meta"] = true 28 | skip["style"] = true 29 | skip["noscript"] = true 30 | } 31 | 32 | // Strip will convert a HTML string to plain text 33 | func Strip(h string, includeLinks bool) string { 34 | h = spaceRe.ReplaceAllString(h, " <") 35 | h = brRe.ReplaceAllString(h, " ") 36 | h = imgRe.ReplaceAllString(h, " <$1") 37 | var buffer bytes.Buffer 38 | doc, err := html.Parse(strings.NewReader(h)) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | extract(doc, &buffer, includeLinks) 44 | return clean(buffer.String()) 45 | } 46 | 47 | func extract(node *html.Node, buff *bytes.Buffer, includeLinks bool) { 48 | if node.Type == html.TextNode { 49 | data := node.Data 50 | if data != "" { 51 | buff.WriteString(data) 52 | } 53 | } 54 | for c := node.FirstChild; c != nil; c = c.NextSibling { 55 | if _, skip := skip[c.Data]; !skip { 56 | if includeLinks && c.Data == "a" { 57 | for _, a := range c.Attr { 58 | if a.Key == "href" && strings.HasPrefix(strings.ToLower(a.Val), "http") { 59 | buff.WriteString(" " + a.Val + " ") 60 | } 61 | } 62 | } 63 | extract(c, buff, includeLinks) 64 | } 65 | } 66 | } 67 | 68 | func clean(text string) string { 69 | // replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184 70 | text = strings.ReplaceAll(text, string('\uFEFF'), " ") 71 | 72 | // remove non-printable characters 73 | text = strings.Map(func(r rune) rune { 74 | if unicode.IsPrint(r) { 75 | return r 76 | } 77 | return []rune(" ")[0] 78 | }, text) 79 | 80 | text = re.ReplaceAllString(text, " ") 81 | 82 | return strings.TrimSpace(text) 83 | } 84 | -------------------------------------------------------------------------------- /internal/htmlcheck/README.md: -------------------------------------------------------------------------------- 1 | # HTML check 2 | 3 | The database used for HTML support tests is based on [can I email](https://www.caniemail.com/). 4 | 5 | The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json) 6 | -------------------------------------------------------------------------------- /internal/htmlcheck/caniemail.go: -------------------------------------------------------------------------------- 1 | // Package htmlcheck is used for parsing HTML and returning 2 | // HTML compatibility errors and warnings 3 | package htmlcheck 4 | 5 | import ( 6 | "embed" 7 | "encoding/json" 8 | "regexp" 9 | ) 10 | 11 | //go:embed caniemail-data.json 12 | var embeddedFS embed.FS 13 | 14 | var ( 15 | cie = CanIEmail{} 16 | 17 | noteMatch = regexp.MustCompile(` #(\d)+$`) 18 | 19 | // LimitFamilies will limit results to families if set 20 | LimitFamilies = []string{} 21 | 22 | // LimitPlatforms will limit results to platforms if set 23 | LimitPlatforms = []string{} 24 | 25 | // LimitClients will limit results to clients if set 26 | LimitClients = []string{} 27 | ) 28 | 29 | // CanIEmail struct for JSON data 30 | type CanIEmail struct { 31 | APIVersion string `json:"api_version"` 32 | LastUpdateDate string `json:"last_update_date"` 33 | // NiceNames map[string]string `json:"last_update_date"` 34 | NiceNames struct { 35 | Family map[string]string `json:"family"` 36 | Platform map[string]string `json:"platform"` 37 | Support map[string]string `json:"support"` 38 | Category map[string]string `json:"category"` 39 | } `json:"nicenames"` 40 | Data []JSONResult `json:"data"` 41 | } 42 | 43 | // JSONResult struct for CanIEmail Data 44 | type JSONResult struct { 45 | Slug string `json:"slug"` 46 | Title string `json:"title"` 47 | Description string `json:"description"` 48 | URL string `json:"url"` 49 | Category string `json:"category"` 50 | Tags []string `json:"tags"` 51 | Keywords string `json:"keywords"` 52 | LastTestDate string `json:"last_test_date"` 53 | TestURL string `json:"test_url"` 54 | TestResultsURL string `json:"test_results_url"` 55 | Stats map[string]interface{} `json:"stats"` 56 | Notes string `json:"notes"` 57 | NotesByNumber map[string]string `json:"notes_by_num"` 58 | } 59 | 60 | // Load the JSON data 61 | func loadJSONData() error { 62 | if cie.APIVersion != "" { 63 | return nil 64 | } 65 | 66 | b, err := embeddedFS.ReadFile("caniemail-data.json") 67 | if err != nil { 68 | return err 69 | } 70 | 71 | cie = CanIEmail{} 72 | 73 | return json.Unmarshal(b, &cie) 74 | } 75 | -------------------------------------------------------------------------------- /internal/htmlcheck/html.go: -------------------------------------------------------------------------------- 1 | package htmlcheck 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/PuerkitoBio/goquery" 8 | "github.com/axllent/mailpit/internal/tools" 9 | ) 10 | 11 | // HTML tests 12 | func runHTMLTests(html string) ([]Warning, int, error) { 13 | results := []Warning{} 14 | totalTests := 0 15 | 16 | reader := strings.NewReader(html) 17 | 18 | // Load the HTML document 19 | doc, err := goquery.NewDocumentFromReader(reader) 20 | if err != nil { 21 | return results, totalTests, err 22 | } 23 | 24 | // Almost all 16 | 17 | 18 |
19 |

HTTP link

20 |

HTTPS link

21 |

HTTPS link

22 |

Localhost link (ignored)

23 |

Localhost link (ignored)

24 |

Single quotes link (ignored)

25 |

26 |

This should be ignored

27 |

Link with spaces

28 |

URL-encoded characters

29 |
30 | 31 | ` 32 | 33 | expectedHTMLLinks = []string{ 34 | "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", 35 | "http://remote-host/style.css", // css 36 | "https://example.com/image.jpg", // images 37 | } 38 | 39 | testTextLinks = `This is a line with http://example.com https://example.com 40 | HTTPS://EXAMPLE.COM 41 | [http://localhost] 42 | www.google.com < ignored 43 | |||http://example.com/?some=query-string||| 44 | ` 45 | 46 | expectedTextLinks = []string{ 47 | "http://example.com", "https://example.com", "HTTPS://EXAMPLE.COM", "http://localhost", "http://example.com/?some=query-string", 48 | } 49 | ) 50 | 51 | func TestLinkDetection(t *testing.T) { 52 | 53 | t.Log("Testing HTML link detection") 54 | 55 | m := storage.Message{} 56 | 57 | m.Text = testTextLinks 58 | m.HTML = testHTML 59 | 60 | textLinks := extractTextLinks(&m) 61 | 62 | if !reflect.DeepEqual(textLinks, expectedTextLinks) { 63 | t.Fatalf("Failed to detect text links correctly") 64 | } 65 | 66 | htmlLinks := extractHTMLLinks(&m) 67 | 68 | if !reflect.DeepEqual(htmlLinks, expectedHTMLLinks) { 69 | t.Fatalf("Failed to detect HTML links correctly") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/linkcheck/main.go: -------------------------------------------------------------------------------- 1 | // Package linkcheck handles message links checking 2 | package linkcheck 3 | 4 | import ( 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/axllent/mailpit/internal/storage" 10 | "github.com/axllent/mailpit/internal/tools" 11 | ) 12 | 13 | var linkRe = regexp.MustCompile(`(?im)\b(http|https):\/\/([\-\w@:%_\+'!.~#?,&\/\/=;]+)`) 14 | 15 | // RunTests will run all tests on an HTML string 16 | func RunTests(msg *storage.Message, followRedirects bool) (Response, error) { 17 | s := Response{} 18 | 19 | allLinks := extractHTMLLinks(msg) 20 | allLinks = strUnique(append(allLinks, extractTextLinks(msg)...)) 21 | s.Links = getHTTPStatuses(allLinks, followRedirects) 22 | 23 | for _, l := range s.Links { 24 | if l.StatusCode >= 400 || l.StatusCode == 0 { 25 | s.Errors++ 26 | } 27 | } 28 | 29 | return s, nil 30 | } 31 | 32 | func extractTextLinks(msg *storage.Message) []string { 33 | links := []string{} 34 | 35 | links = append(links, linkRe.FindAllString(msg.Text, -1)...) 36 | 37 | return links 38 | } 39 | 40 | func extractHTMLLinks(msg *storage.Message) []string { 41 | links := []string{} 42 | 43 | reader := strings.NewReader(msg.HTML) 44 | 45 | // Load the HTML document 46 | doc, err := goquery.NewDocumentFromReader(reader) 47 | if err != nil { 48 | return links 49 | } 50 | 51 | aLinks := doc.Find("a[href]").Nodes 52 | for _, link := range aLinks { 53 | l, err := tools.GetHTMLAttributeVal(link, "href") 54 | if err == nil && linkRe.MatchString(l) { 55 | links = append(links, l) 56 | } 57 | } 58 | 59 | cssLinks := doc.Find("link[rel=\"stylesheet\"]").Nodes 60 | for _, link := range cssLinks { 61 | l, err := tools.GetHTMLAttributeVal(link, "href") 62 | if err == nil && linkRe.MatchString(l) { 63 | links = append(links, l) 64 | } 65 | } 66 | 67 | imgLinks := doc.Find("img[src]").Nodes 68 | for _, link := range imgLinks { 69 | l, err := tools.GetHTMLAttributeVal(link, "src") 70 | if err == nil && linkRe.MatchString(l) { 71 | links = append(links, l) 72 | } 73 | } 74 | 75 | return links 76 | } 77 | 78 | // strUnique return a slice of unique strings from a slice 79 | func strUnique(strSlice []string) []string { 80 | keys := make(map[string]bool) 81 | list := []string{} 82 | for _, entry := range strSlice { 83 | if _, value := keys[entry]; !value { 84 | keys[entry] = true 85 | list = append(list, entry) 86 | } 87 | } 88 | 89 | return list 90 | } 91 | -------------------------------------------------------------------------------- /internal/linkcheck/status.go: -------------------------------------------------------------------------------- 1 | package linkcheck 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "regexp" 7 | "sync" 8 | "time" 9 | 10 | "github.com/axllent/mailpit/config" 11 | "github.com/axllent/mailpit/internal/logger" 12 | ) 13 | 14 | func getHTTPStatuses(links []string, followRedirects bool) []Link { 15 | // allow 5 threads 16 | threads := make(chan int, 5) 17 | 18 | results := make(map[string]Link, len(links)) 19 | resultsMutex := sync.RWMutex{} 20 | 21 | output := []Link{} 22 | 23 | var wg sync.WaitGroup 24 | 25 | for _, l := range links { 26 | wg.Add(1) 27 | go func(link string, w *sync.WaitGroup) { 28 | threads <- 1 // will block if MAX threads 29 | defer w.Done() 30 | 31 | code, err := doHead(link, followRedirects) 32 | l := Link{} 33 | l.URL = link 34 | if err != nil { 35 | l.StatusCode = 0 36 | l.Status = httpErrorSummary(err) 37 | } else { 38 | l.StatusCode = code 39 | l.Status = http.StatusText(code) 40 | } 41 | resultsMutex.Lock() 42 | results[link] = l 43 | resultsMutex.Unlock() 44 | 45 | <-threads // remove from threads 46 | }(l, &wg) 47 | } 48 | 49 | wg.Wait() 50 | 51 | for _, l := range results { 52 | output = append(output, l) 53 | } 54 | 55 | return output 56 | } 57 | 58 | // Do a HEAD request to return HTTP status code 59 | func doHead(link string, followRedirects bool) (int, error) { 60 | 61 | timeout := time.Duration(10 * time.Second) 62 | 63 | tr := &http.Transport{} 64 | 65 | if config.AllowUntrustedTLS { 66 | tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec 67 | } 68 | 69 | client := http.Client{ 70 | Timeout: timeout, 71 | Transport: tr, 72 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 73 | if followRedirects { 74 | return nil 75 | } 76 | return http.ErrUseLastResponse 77 | }, 78 | } 79 | 80 | req, err := http.NewRequest("HEAD", link, nil) 81 | if err != nil { 82 | logger.Log().Errorf("[link-check] %s", err.Error()) 83 | return 0, err 84 | } 85 | 86 | req.Header.Set("User-Agent", "Mailpit/"+config.Version) 87 | 88 | res, err := client.Do(req) 89 | if err != nil { 90 | if res != nil { 91 | return res.StatusCode, err 92 | } 93 | 94 | return 0, err 95 | 96 | } 97 | 98 | return res.StatusCode, nil 99 | } 100 | 101 | // HTTP errors include a lot more info that just the actual error, so this 102 | // tries to take the final part of it, eg: `no such host` 103 | func httpErrorSummary(err error) string { 104 | var re = regexp.MustCompile(`.*: (.*)$`) 105 | 106 | e := err.Error() 107 | if !re.MatchString(e) { 108 | return e 109 | } 110 | 111 | parts := re.FindAllStringSubmatch(e, -1) 112 | 113 | return parts[0][len(parts[0])-1] 114 | } 115 | -------------------------------------------------------------------------------- /internal/linkcheck/structs.go: -------------------------------------------------------------------------------- 1 | package linkcheck 2 | 3 | // Response represents the Link check response 4 | // 5 | // swagger:model LinkCheckResponse 6 | type Response struct { 7 | // Total number of errors 8 | Errors int `json:"Errors"` 9 | // Tested links 10 | Links []Link `json:"Links"` 11 | } 12 | 13 | // Link struct 14 | type Link struct { 15 | // Link URL 16 | URL string `json:"URL"` 17 | // HTTP status code 18 | StatusCode int `json:"StatusCode"` 19 | // HTTP status definition 20 | Status string `json:"Status"` 21 | } 22 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger handles the logging 2 | package logger 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | var ( 15 | log *logrus.Logger 16 | // VerboseLogging for verbose logging 17 | VerboseLogging bool 18 | // QuietLogging shows only errors 19 | QuietLogging bool 20 | // NoLogging shows only fatal errors 21 | NoLogging bool 22 | // LogFile sets a log file 23 | LogFile string 24 | ) 25 | 26 | // Log returns the logger instance 27 | func Log() *logrus.Logger { 28 | if log == nil { 29 | log = logrus.New() 30 | log.SetLevel(logrus.InfoLevel) 31 | if VerboseLogging { 32 | // verbose logging (debug) 33 | log.SetLevel(logrus.DebugLevel) 34 | } else if QuietLogging { 35 | // show errors only 36 | log.SetLevel(logrus.ErrorLevel) 37 | } else if NoLogging { 38 | // disable all logging (tests) 39 | log.SetLevel(logrus.PanicLevel) 40 | } 41 | 42 | if LogFile != "" { 43 | file, err := os.OpenFile(filepath.Clean(LogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0664) // #nosec 44 | if err == nil { 45 | log.Out = file 46 | } else { 47 | log.Out = os.Stdout 48 | log.Warn("Failed to log to file, using default stderr") 49 | } 50 | } else { 51 | log.Out = os.Stdout 52 | } 53 | 54 | log.SetFormatter(&logrus.TextFormatter{ 55 | FullTimestamp: true, 56 | TimestampFormat: "2006/01/02 15:04:05", 57 | }) 58 | } 59 | 60 | return log 61 | } 62 | 63 | // PrettyPrint for debugging 64 | func PrettyPrint(i interface{}) { 65 | s, _ := json.MarshalIndent(i, "", "\t") 66 | fmt.Println(string(s)) 67 | } 68 | 69 | // CleanIP returns a human-readable IP for the logging interface 70 | // when starting services. It translates [::]: to "0.0.0.0:" 71 | func CleanIP(s string) string { 72 | re := regexp.MustCompile(`^\[\:\:\]\:\d+`) 73 | if re.MatchString(s) { 74 | return "0.0.0.0:" + s[5:] 75 | } 76 | 77 | return s 78 | } 79 | 80 | // CleanHTTPIP returns a human-readable IP for the logging interface 81 | // when starting services. It translates [::]: to "localhost:" 82 | func CleanHTTPIP(s string) string { 83 | re := regexp.MustCompile(`^\[\:\:\]\:\d+`) 84 | if re.MatchString(s) { 85 | return "localhost:" + s[5:] 86 | } 87 | 88 | return s 89 | } 90 | -------------------------------------------------------------------------------- /internal/pop3/functions.go: -------------------------------------------------------------------------------- 1 | package pop3 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "strings" 8 | 9 | "github.com/axllent/mailpit/internal/auth" 10 | "github.com/axllent/mailpit/internal/logger" 11 | "github.com/axllent/mailpit/internal/storage" 12 | "github.com/axllent/mailpit/server/websockets" 13 | ) 14 | 15 | func authUser(username, password string) bool { 16 | return auth.POP3Credentials.Match(username, password) 17 | } 18 | 19 | // Send a response with debug logging 20 | func sendResponse(c net.Conn, m string) { 21 | fmt.Fprintf(c, "%s\r\n", m) 22 | logger.Log().Debugf("[pop3] response: %s", m) 23 | 24 | if strings.HasPrefix(m, "-ERR ") { 25 | sub, _ := strings.CutPrefix(m, "-ERR ") 26 | websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub) 27 | } 28 | } 29 | 30 | // Send a response without debug logging (for data) 31 | func sendData(c net.Conn, m string) { 32 | fmt.Fprintf(c, "%s\r\n", m) 33 | } 34 | 35 | // Get the latest 100 messages 36 | func getMessages() ([]message, error) { 37 | messages := []message{} 38 | list, err := storage.List(0, 0, 100) 39 | if err != nil { 40 | return messages, err 41 | } 42 | 43 | for _, m := range list { 44 | msg := message{} 45 | msg.ID = m.ID 46 | msg.Size = m.Size 47 | messages = append(messages, msg) 48 | } 49 | 50 | return messages, nil 51 | } 52 | 53 | // POP3 TOP command returns the headers, followed by the next x lines 54 | func getTop(id string, nr int) (string, string, error) { 55 | var header, body string 56 | raw, err := storage.GetMessageRaw(id) 57 | if err != nil { 58 | return header, body, errors.New("-ERR no such message") 59 | } 60 | 61 | parts := strings.SplitN(string(raw), "\r\n\r\n", 2) 62 | header = parts[0] 63 | lines := []string{} 64 | if nr > 0 && len(parts) == 2 { 65 | lines = strings.SplitN(parts[1], "\r\n", nr) 66 | } 67 | 68 | return header, strings.Join(lines, "\r\n"), nil 69 | } 70 | 71 | // cuts the line into command and arguments 72 | func getCommand(line string) (string, []string) { 73 | line = strings.Trim(line, "\r \n") 74 | cmd := strings.Split(line, " ") 75 | return cmd[0], cmd[1:] 76 | } 77 | 78 | func getSafeArg(args []string, nr int) (string, error) { 79 | if nr < len(args) { 80 | return args[nr], nil 81 | } 82 | 83 | return "", errors.New("-ERR out of range") 84 | } 85 | -------------------------------------------------------------------------------- /internal/smtpd/chaos/chaos.go: -------------------------------------------------------------------------------- 1 | // Package chaos is used to simulate Chaos engineering (random failures) in the SMTPD server. 2 | // See https://en.wikipedia.org/wiki/Chaos_engineering 3 | // See https://mailpit.axllent.org/docs/integration/chaos/ 4 | package chaos 5 | 6 | import ( 7 | "crypto/rand" 8 | "fmt" 9 | "math/big" 10 | "strings" 11 | 12 | "github.com/axllent/mailpit/internal/logger" 13 | ) 14 | 15 | var ( 16 | // Enabled is a flag to enable or disable support for chaos 17 | Enabled = false 18 | 19 | // Config is the global Chaos configuration 20 | Config = Triggers{ 21 | Sender: Trigger{ErrorCode: 451, Probability: 0}, 22 | Recipient: Trigger{ErrorCode: 451, Probability: 0}, 23 | Authentication: Trigger{ErrorCode: 535, Probability: 0}, 24 | } 25 | ) 26 | 27 | // Triggers for the Chaos configuration 28 | // swagger:model Triggers 29 | type Triggers struct { 30 | // Sender trigger to fail on From, Sender 31 | Sender Trigger 32 | // Recipient trigger to fail on To, Cc, Bcc 33 | Recipient Trigger 34 | // Authentication trigger to fail while authenticating (auth must be configured) 35 | Authentication Trigger 36 | } 37 | 38 | // Trigger for Chaos 39 | // swagger:model Trigger 40 | type Trigger struct { 41 | // SMTP error code to return. The value must range from 400 to 599. 42 | // required: true 43 | // example: 451 44 | ErrorCode int 45 | 46 | // Probability (chance) of triggering the error. The value must range from 0 to 100. 47 | // required: true 48 | // example: 5 49 | Probability int 50 | } 51 | 52 | // SetFromStruct will set a whole map of chaos configurations (ie: API) 53 | func SetFromStruct(c Triggers) error { 54 | if c.Sender.ErrorCode == 0 { 55 | c.Sender.ErrorCode = 451 // default 56 | } 57 | 58 | if c.Recipient.ErrorCode == 0 { 59 | c.Recipient.ErrorCode = 451 // default 60 | } 61 | 62 | if c.Authentication.ErrorCode == 0 { 63 | c.Authentication.ErrorCode = 535 // default 64 | } 65 | 66 | if err := Set("Sender", c.Sender.ErrorCode, c.Sender.Probability); err != nil { 67 | return err 68 | } 69 | if err := Set("Recipient", c.Recipient.ErrorCode, c.Recipient.Probability); err != nil { 70 | return err 71 | } 72 | if err := Set("Authentication", c.Authentication.ErrorCode, c.Authentication.Probability); err != nil { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | // Set will set the chaos configuration for the given key (CLI & setMap()) 80 | func Set(key string, errorCode int, probability int) error { 81 | Enabled = true 82 | if errorCode < 400 || errorCode > 599 { 83 | return fmt.Errorf("error code must be between 400 and 599") 84 | } 85 | 86 | if probability > 100 || probability < 0 { 87 | return fmt.Errorf("probability must be between 0 and 100") 88 | } 89 | 90 | key = strings.ToLower(key) 91 | 92 | switch key { 93 | case "sender": 94 | Config.Sender = Trigger{ErrorCode: errorCode, Probability: probability} 95 | logger.Log().Infof("[chaos] Sender to return %d error with %d%% probability", errorCode, probability) 96 | case "recipient", "recipients": 97 | Config.Recipient = Trigger{ErrorCode: errorCode, Probability: probability} 98 | logger.Log().Infof("[chaos] Recipient to return %d error with %d%% probability", errorCode, probability) 99 | case "auth", "authentication": 100 | Config.Authentication = Trigger{ErrorCode: errorCode, Probability: probability} 101 | logger.Log().Infof("[chaos] Authentication to return %d error with %d%% probability", errorCode, probability) 102 | default: 103 | return fmt.Errorf("unknown key %s", key) 104 | } 105 | 106 | return nil 107 | } 108 | 109 | // Trigger will return whether the Chaos rule is triggered based on the configuration 110 | // and a randomly-generated percentage value. 111 | func (c Trigger) Trigger() (bool, int) { 112 | if !Enabled || c.Probability == 0 { 113 | return false, 0 114 | } 115 | 116 | nBig, _ := rand.Int(rand.Reader, big.NewInt(100)) 117 | 118 | // rand.IntN(100) will return 0-99, whereas probability is 1-100, 119 | // so value must be less than (not <=) to the probability to trigger 120 | return int(nBig.Int64()) < c.Probability, c.ErrorCode 121 | } 122 | -------------------------------------------------------------------------------- /internal/smtpd/forward.go: -------------------------------------------------------------------------------- 1 | package smtpd 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/smtp" 7 | "strings" 8 | 9 | "github.com/axllent/mailpit/config" 10 | "github.com/axllent/mailpit/internal/logger" 11 | "github.com/axllent/mailpit/internal/tools" 12 | ) 13 | 14 | // Wrapper to forward messages if configured 15 | func autoForwardMessage(from string, data *[]byte) { 16 | if config.SMTPForwardConfig.Host == "" { 17 | return 18 | } 19 | 20 | if err := forward(from, *data); err != nil { 21 | logger.Log().Errorf("[forward] error: %s", err.Error()) 22 | } else { 23 | logger.Log().Debugf("[forward] message from %s to %s via %s:%d", 24 | from, config.SMTPForwardConfig.To, config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port) 25 | } 26 | } 27 | 28 | func createForwardingSMTPClient(config config.SMTPForwardConfigStruct, addr string) (*smtp.Client, error) { 29 | if config.TLS { 30 | tlsConf := &tls.Config{ServerName: config.Host} // #nosec 31 | tlsConf.InsecureSkipVerify = config.AllowInsecure 32 | 33 | conn, err := tls.Dial("tcp", addr, tlsConf) 34 | if err != nil { 35 | return nil, fmt.Errorf("TLS dial error: %v", err) 36 | } 37 | 38 | client, err := smtp.NewClient(conn, tlsConf.ServerName) 39 | if err != nil { 40 | conn.Close() 41 | return nil, fmt.Errorf("SMTP client error: %v", err) 42 | } 43 | 44 | // Note: The caller is responsible for closing the client 45 | return client, nil 46 | } 47 | 48 | client, err := smtp.Dial(addr) 49 | if err != nil { 50 | return nil, fmt.Errorf("error connecting to %s: %v", addr, err) 51 | } 52 | 53 | if config.STARTTLS { 54 | tlsConf := &tls.Config{ServerName: config.Host} // #nosec 55 | tlsConf.InsecureSkipVerify = config.AllowInsecure 56 | 57 | if err = client.StartTLS(tlsConf); err != nil { 58 | client.Close() 59 | return nil, fmt.Errorf("error creating StartTLS config: %v", err) 60 | } 61 | } 62 | 63 | // Note: The caller is responsible for closing the client 64 | return client, nil 65 | } 66 | 67 | // Forward will connect to a pre-configured SMTP server and send a message to one or more recipients. 68 | func forward(from string, msg []byte) error { 69 | addr := fmt.Sprintf("%s:%d", config.SMTPForwardConfig.Host, config.SMTPForwardConfig.Port) 70 | 71 | c, err := createForwardingSMTPClient(config.SMTPForwardConfig, addr) 72 | if err != nil { 73 | return err 74 | } 75 | defer c.Close() 76 | 77 | auth := forwardAuthFromConfig() 78 | 79 | if auth != nil { 80 | if err = c.Auth(auth); err != nil { 81 | return fmt.Errorf("error response to AUTH command: %s", err.Error()) 82 | } 83 | } 84 | 85 | if config.SMTPForwardConfig.OverrideFrom != "" { 86 | msg, err = tools.OverrideFromHeader(msg, config.SMTPForwardConfig.OverrideFrom) 87 | if err != nil { 88 | return fmt.Errorf("error overriding From header: %s", err.Error()) 89 | } 90 | 91 | from = config.SMTPForwardConfig.OverrideFrom 92 | } 93 | 94 | if err = c.Mail(from); err != nil { 95 | return fmt.Errorf("error response to MAIL command: %s", err.Error()) 96 | } 97 | 98 | to := strings.Split(config.SMTPForwardConfig.To, ",") 99 | 100 | for _, addr := range to { 101 | if err = c.Rcpt(addr); err != nil { 102 | logger.Log().Warnf("error response to RCPT command for %s: %s", addr, err.Error()) 103 | } 104 | } 105 | 106 | w, err := c.Data() 107 | if err != nil { 108 | return fmt.Errorf("error response to DATA command: %s", err.Error()) 109 | } 110 | 111 | if _, err := w.Write(msg); err != nil { 112 | return fmt.Errorf("error sending message: %s", err.Error()) 113 | } 114 | 115 | if err := w.Close(); err != nil { 116 | return fmt.Errorf("error closing connection: %s", err.Error()) 117 | } 118 | 119 | return c.Quit() 120 | } 121 | 122 | // Return the SMTP forwarding authentication based on config 123 | func forwardAuthFromConfig() smtp.Auth { 124 | var a smtp.Auth 125 | 126 | if config.SMTPForwardConfig.Auth == "plain" { 127 | a = smtp.PlainAuth("", config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password, config.SMTPForwardConfig.Host) 128 | } 129 | 130 | if config.SMTPForwardConfig.Auth == "login" { 131 | a = LoginAuth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Password) 132 | } 133 | 134 | if config.SMTPForwardConfig.Auth == "cram-md5" { 135 | a = smtp.CRAMMD5Auth(config.SMTPForwardConfig.Username, config.SMTPForwardConfig.Secret) 136 | } 137 | 138 | return a 139 | } 140 | -------------------------------------------------------------------------------- /internal/spamassassin/postmark/postmark.go: -------------------------------------------------------------------------------- 1 | // Package postmark uses the free https://spamcheck.postmarkapp.com/ 2 | // See https://spamcheck.postmarkapp.com/doc/ for more details. 3 | package postmark 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "regexp" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | // Response struct 16 | type Response struct { 17 | Success bool `json:"success"` 18 | Message string `json:"message"` // for errors only 19 | Score string `json:"score"` 20 | Rules []Rule `json:"rules"` 21 | Report string `json:"report"` // ignored 22 | } 23 | 24 | // Rule struct 25 | type Rule struct { 26 | Score string `json:"score"` 27 | // Name not returned by postmark but rather extracted from description 28 | Name string `json:"name"` 29 | Description string `json:"description"` 30 | } 31 | 32 | // Check will post the email data to Postmark 33 | func Check(email []byte, timeout int) (Response, error) { 34 | r := Response{} 35 | // '{"email":"raw dump of email", "options":"short"}' 36 | var d struct { 37 | // The raw dump of the email to be filtered, including all headers. 38 | Email string `json:"email"` 39 | // Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request. 40 | Options string `json:"options"` 41 | } 42 | 43 | d.Email = string(email) 44 | d.Options = "long" 45 | 46 | data, err := json.Marshal(d) 47 | if err != nil { 48 | return r, err 49 | } 50 | 51 | client := http.Client{ 52 | Timeout: time.Duration(timeout) * time.Second, 53 | } 54 | 55 | resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json", 56 | bytes.NewBuffer(data)) 57 | 58 | if err != nil { 59 | return r, err 60 | } 61 | 62 | defer resp.Body.Close() 63 | 64 | err = json.NewDecoder(resp.Body).Decode(&r) 65 | 66 | // remove trailing line spaces for all lines in report 67 | re := regexp.MustCompile("\r?\n") 68 | lines := re.Split(r.Report, -1) 69 | reportLines := []string{} 70 | for _, l := range lines { 71 | line := strings.TrimRight(l, " ") 72 | reportLines = append(reportLines, line) 73 | } 74 | reportRaw := strings.Join(reportLines, "\n") 75 | 76 | // join description lines to make a single line per rule 77 | re2 := regexp.MustCompile("\n ") 78 | report := re2.ReplaceAllString(reportRaw, "") 79 | for i, rule := range r.Rules { 80 | // populate rule name 81 | r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report) 82 | } 83 | 84 | return r, err 85 | } 86 | 87 | // Extract the name of the test from the report as Postmark does not include this in the JSON reports 88 | func nameFromReport(score, description, report string) string { 89 | score = regexp.QuoteMeta(score) 90 | description = regexp.QuoteMeta(description) 91 | str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description) 92 | re := regexp.MustCompile(str) 93 | 94 | matches := re.FindAllStringSubmatch(report, 1) 95 | if len(matches) > 0 && len(matches[0]) == 2 { 96 | return strings.TrimSpace(matches[0][1]) 97 | } 98 | 99 | return "" 100 | } 101 | -------------------------------------------------------------------------------- /internal/spamassassin/spamassassin.go: -------------------------------------------------------------------------------- 1 | // Package spamassassin will return results from either a SpamAssassin server or 2 | // Postmark's public API depending on configuration 3 | package spamassassin 4 | 5 | import ( 6 | "errors" 7 | "math" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/axllent/mailpit/internal/spamassassin/postmark" 12 | "github.com/axllent/mailpit/internal/spamassassin/spamc" 13 | ) 14 | 15 | var ( 16 | // Service to use, either ":" for self-hosted SpamAssassin or "postmark" 17 | service string 18 | 19 | // SpamScore is the score at which a message is determined to be spam 20 | spamScore = 5.0 21 | 22 | // Timeout in seconds 23 | timeout = 8 24 | ) 25 | 26 | // Result is a SpamAssassin result 27 | // 28 | // swagger:model SpamAssassinResponse 29 | type Result struct { 30 | // Whether the message is spam or not 31 | IsSpam bool 32 | // If populated will return an error string 33 | Error string 34 | // Total spam score based on triggered rules 35 | Score float64 36 | // Spam rules triggered 37 | Rules []Rule 38 | } 39 | 40 | // Rule struct 41 | type Rule struct { 42 | // Spam rule score 43 | Score float64 44 | // SpamAssassin rule name 45 | Name string 46 | // SpamAssassin rule description 47 | Description string 48 | } 49 | 50 | // SetService defines which service should be used. 51 | func SetService(s string) { 52 | switch s { 53 | case "postmark": 54 | service = "postmark" 55 | default: 56 | service = s 57 | } 58 | } 59 | 60 | // SetTimeout defines the timeout 61 | func SetTimeout(t int) { 62 | if t > 0 { 63 | timeout = t 64 | } 65 | } 66 | 67 | // Ping returns whether a service is active or not 68 | func Ping() error { 69 | if service == "postmark" { 70 | return nil 71 | } 72 | 73 | var client *spamc.Client 74 | if strings.HasPrefix(service, "unix:") { 75 | client = spamc.NewUnix(strings.TrimLeft(service, "unix:")) 76 | } else { 77 | client = spamc.NewTCP(service, timeout) 78 | } 79 | 80 | return client.Ping() 81 | } 82 | 83 | // Check will return a Result 84 | func Check(msg []byte) (Result, error) { 85 | r := Result{Score: 0} 86 | 87 | if service == "" { 88 | return r, errors.New("no SpamAssassin service defined") 89 | } 90 | 91 | if service == "postmark" { 92 | res, err := postmark.Check(msg, timeout) 93 | if err != nil { 94 | r.Error = err.Error() 95 | return r, nil 96 | } 97 | resFloat, err := strconv.ParseFloat(res.Score, 32) 98 | if err == nil { 99 | r.Score = round1dm(resFloat) 100 | r.IsSpam = resFloat >= spamScore 101 | } 102 | r.Error = res.Message 103 | for _, pr := range res.Rules { 104 | rule := Rule{} 105 | value, err := strconv.ParseFloat(pr.Score, 32) 106 | if err == nil { 107 | rule.Score = round1dm(value) 108 | } 109 | rule.Name = pr.Name 110 | rule.Description = pr.Description 111 | r.Rules = append(r.Rules, rule) 112 | } 113 | } else { 114 | var client *spamc.Client 115 | if strings.HasPrefix(service, "unix:") { 116 | client = spamc.NewUnix(strings.TrimLeft(service, "unix:")) 117 | } else { 118 | client = spamc.NewTCP(service, timeout) 119 | } 120 | 121 | res, err := client.Report(msg) 122 | if err != nil { 123 | r.Error = err.Error() 124 | return r, nil 125 | } 126 | r.IsSpam = res.Score >= spamScore 127 | r.Score = round1dm(res.Score) 128 | r.Rules = []Rule{} 129 | for _, sr := range res.Rules { 130 | rule := Rule{} 131 | value, err := strconv.ParseFloat(sr.Points, 32) 132 | if err == nil { 133 | rule.Score = round1dm(value) 134 | } 135 | rule.Name = sr.Name 136 | rule.Description = sr.Description 137 | r.Rules = append(r.Rules, rule) 138 | } 139 | } 140 | 141 | return r, nil 142 | } 143 | 144 | // Round to one decimal place 145 | func round1dm(n float64) float64 { 146 | return math.Floor(n*10) / 10 147 | } 148 | -------------------------------------------------------------------------------- /internal/stats/stats.go: -------------------------------------------------------------------------------- 1 | // Package stats stores and returns Mailpit statistics 2 | package stats 3 | 4 | import ( 5 | "runtime" 6 | "sync" 7 | "time" 8 | 9 | "github.com/axllent/mailpit/config" 10 | "github.com/axllent/mailpit/internal/storage" 11 | "github.com/axllent/mailpit/internal/updater" 12 | ) 13 | 14 | var ( 15 | // to prevent hammering Github for latest version 16 | latestVersionCache string 17 | 18 | // StartedAt is set to the current ime when Mailpit starts 19 | startedAt time.Time 20 | 21 | mu sync.RWMutex 22 | 23 | smtpAccepted uint64 24 | smtpAcceptedSize uint64 25 | smtpRejected uint64 26 | smtpIgnored uint64 27 | ) 28 | 29 | // AppInformation struct 30 | // swagger:model AppInformation 31 | type AppInformation struct { 32 | // Current Mailpit version 33 | Version string 34 | // Latest Mailpit version 35 | LatestVersion string 36 | // Database path 37 | Database string 38 | // Database size in bytes 39 | DatabaseSize uint64 40 | // Total number of messages in the database 41 | Messages uint64 42 | // Total number of messages in the database 43 | Unread uint64 44 | // Tags and message totals per tag 45 | Tags map[string]int64 46 | // Runtime statistics 47 | RuntimeStats struct { 48 | // Mailpit server uptime in seconds 49 | Uptime uint64 50 | // Current memory usage in bytes 51 | Memory uint64 52 | // Database runtime messages deleted 53 | MessagesDeleted uint64 54 | // Accepted runtime SMTP messages 55 | SMTPAccepted uint64 56 | // Total runtime accepted messages size in bytes 57 | SMTPAcceptedSize uint64 58 | // Rejected runtime SMTP messages 59 | SMTPRejected uint64 60 | // Ignored runtime SMTP messages (when using --ignore-duplicate-ids) 61 | SMTPIgnored uint64 62 | } 63 | } 64 | 65 | // Load the current statistics 66 | func Load() AppInformation { 67 | info := AppInformation{} 68 | info.Version = config.Version 69 | 70 | var m runtime.MemStats 71 | runtime.ReadMemStats(&m) 72 | 73 | info.RuntimeStats.Memory = m.Sys - m.HeapReleased 74 | info.RuntimeStats.Uptime = uint64(time.Since(startedAt).Seconds()) 75 | info.RuntimeStats.MessagesDeleted = storage.StatsDeleted 76 | info.RuntimeStats.SMTPAccepted = smtpAccepted 77 | info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize 78 | info.RuntimeStats.SMTPRejected = smtpRejected 79 | info.RuntimeStats.SMTPIgnored = smtpIgnored 80 | 81 | if latestVersionCache != "" { 82 | info.LatestVersion = latestVersionCache 83 | } else { 84 | latest, _, _, err := updater.GithubLatest(config.Repo, config.RepoBinaryName) 85 | if err == nil { 86 | info.LatestVersion = latest 87 | latestVersionCache = latest 88 | 89 | // clear latest version cache after 5 minutes 90 | go func() { 91 | time.Sleep(15 * time.Minute) 92 | latestVersionCache = "" 93 | }() 94 | } 95 | } 96 | 97 | info.Database = config.Database 98 | info.DatabaseSize = storage.DbSize() 99 | info.Messages = storage.CountTotal() 100 | info.Unread = storage.CountUnread() 101 | info.Tags = storage.GetAllTagsCount() 102 | 103 | return info 104 | } 105 | 106 | // Track will start the statistics logging in memory 107 | func Track() { 108 | startedAt = time.Now() 109 | } 110 | 111 | // LogSMTPAccepted logs a successful SMTP transaction 112 | func LogSMTPAccepted(size int) { 113 | mu.Lock() 114 | smtpAccepted = smtpAccepted + 1 115 | smtpAcceptedSize = smtpAcceptedSize + uint64(size) 116 | mu.Unlock() 117 | } 118 | 119 | // LogSMTPRejected logs a rejected SMTP transaction 120 | func LogSMTPRejected() { 121 | mu.Lock() 122 | smtpRejected = smtpRejected + 1 123 | mu.Unlock() 124 | } 125 | 126 | // LogSMTPIgnored logs an ignored SMTP transaction 127 | func LogSMTPIgnored() { 128 | mu.Lock() 129 | smtpIgnored = smtpIgnored + 1 130 | mu.Unlock() 131 | } 132 | -------------------------------------------------------------------------------- /internal/storage/notifications.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/axllent/mailpit/config" 7 | "github.com/axllent/mailpit/server/websockets" 8 | ) 9 | 10 | var bcStatsDelay = false 11 | 12 | // BroadcastMailboxStats broadcasts the total number of messages 13 | // displayed to the web UI, as well as the total unread messages. 14 | // The lookup is very fast (< 10ms / 100k messages under load). 15 | // Rate limited to 4x per second. 16 | func BroadcastMailboxStats() { 17 | if bcStatsDelay { 18 | return 19 | } 20 | 21 | bcStatsDelay = true 22 | 23 | go func() { 24 | time.Sleep(250 * time.Millisecond) 25 | bcStatsDelay = false 26 | b := struct { 27 | Total uint64 28 | Unread uint64 29 | Version string 30 | }{ 31 | Total: CountTotal(), 32 | Unread: CountUnread(), 33 | Version: config.Version, 34 | } 35 | 36 | websockets.Broadcast("stats", b) 37 | }() 38 | } 39 | -------------------------------------------------------------------------------- /internal/storage/reindex.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "encoding/json" 8 | "fmt" 9 | "net/mail" 10 | "os" 11 | 12 | "github.com/axllent/mailpit/internal/logger" 13 | "github.com/axllent/mailpit/internal/tools" 14 | "github.com/jhillyerd/enmime/v2" 15 | "github.com/leporo/sqlf" 16 | ) 17 | 18 | // ReindexAll will regenerate the search text and snippet for a message 19 | // and update the database. 20 | func ReindexAll() { 21 | ids := []string{} 22 | var i string 23 | chunkSize := 1000 24 | 25 | finished := 0 26 | 27 | err := sqlf.Select("ID").To(&i). 28 | From(tenant("mailbox")). 29 | OrderBy("Created DESC"). 30 | QueryAndClose(context.TODO(), db, func(row *sql.Rows) { 31 | ids = append(ids, i) 32 | }) 33 | 34 | if err != nil { 35 | logger.Log().Errorf("[db] %s", err.Error()) 36 | os.Exit(1) 37 | } 38 | 39 | total := len(ids) 40 | 41 | chunks := chunkBy(ids, chunkSize) 42 | 43 | logger.Log().Infof("reindexing %d messages", total) 44 | 45 | type updateStruct struct { 46 | // ID in database 47 | ID string 48 | // SearchText for searching 49 | SearchText string 50 | // Snippet for UI 51 | Snippet string 52 | // Metadata info 53 | Metadata string 54 | } 55 | 56 | parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) 57 | 58 | for _, ids := range chunks { 59 | updates := []updateStruct{} 60 | 61 | for _, id := range ids { 62 | raw, err := GetMessageRaw(id) 63 | if err != nil { 64 | logger.Log().Error(err) 65 | continue 66 | } 67 | 68 | r := bytes.NewReader(raw) 69 | 70 | env, err := parser.ReadEnvelope(r) 71 | if err != nil { 72 | logger.Log().Errorf("[message] %s", err.Error()) 73 | continue 74 | } 75 | 76 | from := &mail.Address{} 77 | fromJSON := addressToSlice(env, "From") 78 | if len(fromJSON) > 0 { 79 | from = fromJSON[0] 80 | } else if env.GetHeader("From") != "" { 81 | from = &mail.Address{Name: env.GetHeader("From")} 82 | } 83 | 84 | obj := DBMailSummary{ 85 | From: from, 86 | To: addressToSlice(env, "To"), 87 | Cc: addressToSlice(env, "Cc"), 88 | Bcc: addressToSlice(env, "Bcc"), 89 | ReplyTo: addressToSlice(env, "Reply-To"), 90 | } 91 | 92 | MetadataJSON, err := json.Marshal(obj) 93 | if err != nil { 94 | logger.Log().Errorf("[message] %s", err.Error()) 95 | continue 96 | } 97 | 98 | searchText := createSearchText(env) 99 | snippet := tools.CreateSnippet(env.Text, env.HTML) 100 | 101 | u := updateStruct{} 102 | u.ID = id 103 | u.SearchText = searchText 104 | u.Snippet = snippet 105 | u.Metadata = string(MetadataJSON) 106 | 107 | updates = append(updates, u) 108 | } 109 | 110 | ctx := context.Background() 111 | tx, err := db.BeginTx(ctx, nil) 112 | if err != nil { 113 | logger.Log().Errorf("[db] %s", err.Error()) 114 | continue 115 | } 116 | 117 | // roll back if it fails 118 | defer tx.Rollback() 119 | 120 | // insert mail summary data 121 | for _, u := range updates { 122 | _, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID) 123 | if err != nil { 124 | logger.Log().Errorf("[db] %s", err.Error()) 125 | continue 126 | } 127 | } 128 | 129 | if err := tx.Commit(); err != nil { 130 | logger.Log().Errorf("[db] %s", err.Error()) 131 | continue 132 | } 133 | 134 | finished += len(updates) 135 | 136 | logger.Log().Printf("reindexed: %d / %d (%d%%)", finished, total, finished*100/total) 137 | } 138 | } 139 | 140 | func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) { 141 | for chunkSize < len(items) { 142 | items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize]) 143 | } 144 | 145 | return append(chunks, items) 146 | } 147 | -------------------------------------------------------------------------------- /internal/storage/schemas.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "log" 7 | "path" 8 | "sort" 9 | "strings" 10 | "text/template" 11 | 12 | "github.com/axllent/mailpit/internal/logger" 13 | "github.com/axllent/semver" 14 | ) 15 | 16 | //go:embed schemas/* 17 | var schemaScripts embed.FS 18 | 19 | // Create tables and apply schemas if required 20 | func dbApplySchemas() error { 21 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil { 22 | return err 23 | } 24 | 25 | var legacyMigrationTable int 26 | err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | if legacyMigrationTable == 1 { 32 | rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations")) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | legacySchemas := []string{} 38 | 39 | for rows.Next() { 40 | var oldID string 41 | if err := rows.Scan(&oldID); err == nil { 42 | legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID)) 43 | } 44 | } 45 | 46 | legacySchemas = semver.SortMin(legacySchemas) 47 | 48 | for _, v := range legacySchemas { 49 | var migrated int 50 | err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated) 51 | if err != nil { 52 | return err 53 | } 54 | if migrated == 0 { 55 | // copy to tenant("schemas") 56 | if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil { 57 | return err 58 | } 59 | } 60 | } 61 | } 62 | 63 | schemaFiles, err := schemaScripts.ReadDir("schemas") 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | 68 | temp := template.New("") 69 | temp.Funcs( 70 | template.FuncMap{ 71 | "tenant": tenant, 72 | }, 73 | ) 74 | 75 | type schema struct { 76 | Name string 77 | Semver string 78 | } 79 | 80 | scripts := []schema{} 81 | 82 | for _, s := range schemaFiles { 83 | if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") { 84 | continue 85 | } 86 | 87 | schemaID := strings.TrimRight(s.Name(), ".sql") 88 | 89 | if !semver.IsValid(schemaID) { 90 | logger.Log().Warnf("[db] invalid schema name: %s", s.Name()) 91 | continue 92 | } 93 | 94 | script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)} 95 | scripts = append(scripts, script) 96 | } 97 | 98 | // sort schemas by semver, low to high 99 | sort.Slice(scripts, func(i, j int) bool { 100 | return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1 101 | }) 102 | 103 | for _, s := range scripts { 104 | var complete int 105 | err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if complete == 1 { 111 | // already completed, ignore 112 | continue 113 | } 114 | // use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305 115 | b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name)) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | // parse import script 121 | t1, err := temp.Parse(string(b)) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | buf := new(bytes.Buffer) 127 | 128 | if err := t1.Execute(buf, nil); err != nil { 129 | return err 130 | } 131 | 132 | if _, err := db.Exec(buf.String()); err != nil { 133 | return err 134 | } 135 | 136 | if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil { 137 | return err 138 | } 139 | 140 | logger.Log().Debugf("[db] applied schema: %s", s.Name) 141 | } 142 | 143 | return nil 144 | } 145 | 146 | // These functions are used to migrate data formats/structure on startup. 147 | func dataMigrations() { 148 | // ensure DeletedSize has a value if empty 149 | if SettingGet("DeletedSize") == "" { 150 | _ = SettingPut("DeletedSize", "0") 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.0.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE TABLES 2 | CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} ( 3 | Sort INTEGER PRIMARY KEY AUTOINCREMENT, 4 | ID TEXT NOT NULL, 5 | Data BLOB, 6 | Search TEXT, 7 | Read INTEGER 8 | ); 9 | 10 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort); 11 | CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID); 12 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read); 13 | 14 | CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} ( 15 | ID TEXT KEY NOT NULL, 16 | Email BLOB 17 | ); 18 | 19 | CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID); 20 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.1.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE TAGS COLUMN 2 | ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]'; 3 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags); 4 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.2.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATING NEW MAILBOX FORMAT 2 | CREATE TABLE IF NOT EXISTS {{ tenant "mailboxtmp" }} ( 3 | Created INTEGER NOT NULL, 4 | ID TEXT NOT NULL, 5 | MessageID TEXT NOT NULL, 6 | Subject TEXT NOT NULL, 7 | Metadata TEXT, 8 | Size INTEGER NOT NULL, 9 | Inline INTEGER NOT NULL, 10 | Attachments INTEGER NOT NULL, 11 | Read INTEGER, 12 | Tags TEXT, 13 | SearchText TEXT 14 | ); 15 | 16 | INSERT INTO {{ tenant "mailboxtmp" }} 17 | (Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags) 18 | SELECT 19 | Sort, ID, '', json_extract(Data, '$.Subject'),Data, 20 | json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'), 21 | Search, Read, Tags 22 | FROM {{ tenant "mailbox" }}; 23 | 24 | DROP TABLE IF EXISTS {{ tenant "mailbox" }}; 25 | 26 | ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }}; 27 | 28 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created); 29 | CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID); 30 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID); 31 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject); 32 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size); 33 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline); 34 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments); 35 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read); 36 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags); 37 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.21.2.sql: -------------------------------------------------------------------------------- 1 | -- DROP LEGACY MIGRATION TABLE 2 | DROP TABLE IF EXISTS {{ tenant "darwin_migrations" }}; 3 | 4 | -- DROP LEGACY TAGS COLUMN 5 | DROP INDEX IF EXISTS {{ tenant "idx_tags" }}; 6 | ALTER TABLE {{ tenant "mailbox" }} DROP COLUMN Tags; 7 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.21.8.sql: -------------------------------------------------------------------------------- 1 | -- Rebuild message_tags to remove FOREIGN KEY REFERENCES 2 | PRAGMA foreign_keys=OFF; 3 | 4 | DROP INDEX IF EXISTS {{ tenant "idx_message_tag_id" }}; 5 | DROP INDEX IF EXISTS {{ tenant "idx_message_tag_tagid" }}; 6 | 7 | ALTER TABLE {{ tenant "message_tags" }} RENAME TO _{{ tenant "message_tags" }}_old; 8 | 9 | CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} ( 10 | Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 11 | ID TEXT NOT NULL, 12 | TagID INTEGER NOT NULL 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_id" }} ON {{ tenant "message_tags" }} (ID); 16 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tags_tagid" }} ON {{ tenant "message_tags" }} (TagID); 17 | 18 | INSERT INTO {{ tenant "message_tags" }} SELECT * FROM _{{ tenant "message_tags" }}_old; 19 | 20 | DROP TABLE IF EXISTS _{{ tenant "message_tags" }}_old; 21 | 22 | PRAGMA foreign_keys=ON; 23 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.23.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE Compressed COLUMN IN mailbox_data 2 | ALTER TABLE {{ tenant "mailbox_data" }} ADD COLUMN Compressed INTEGER NOT NULL DEFAULT '0'; 3 | 4 | -- SET Compressed = 1 for all existing data 5 | UPDATE {{ tenant "mailbox_data" }} SET Compressed = 1; 6 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.3.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE SNIPPET COLUMN 2 | ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.4.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE TAG TABLES 2 | CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} ( 3 | ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | Name TEXT COLLATE NOCASE 5 | ); 6 | 7 | CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name); 8 | 9 | CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} ( 10 | Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 11 | ID TEXT REFERENCES {{ tenant "mailbox" }} (ID), 12 | TagID INT REFERENCES {{ tenant "tags" }} (ID) 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID); 16 | CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID); 17 | -------------------------------------------------------------------------------- /internal/storage/schemas/1.5.0.sql: -------------------------------------------------------------------------------- 1 | -- CREATE SETTINGS TABLE 2 | CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} ( 3 | Key TEXT, 4 | Value TEXT 5 | ); 6 | CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key); 7 | INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }})); 8 | -------------------------------------------------------------------------------- /internal/storage/schemas/README.md: -------------------------------------------------------------------------------- 1 | # Migration scripts 2 | 3 | - Scripts should be named using semver and have the `.sql` extension. 4 | - Inline comments should be prefixed with a `--` 5 | - All references to tables and indexes should be wrapped with a `{{ tenant "" }}` 6 | -------------------------------------------------------------------------------- /internal/storage/settings.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/axllent/mailpit/internal/logger" 8 | "github.com/leporo/sqlf" 9 | ) 10 | 11 | // SettingGet returns a setting string value, blank is it does not exist 12 | func SettingGet(k string) string { 13 | var result sql.NullString 14 | err := sqlf.From(tenant("settings")). 15 | Select("Value").To(&result). 16 | Where("Key = ?", k). 17 | Limit(1). 18 | QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) 19 | if err != nil { 20 | logger.Log().Errorf("[db] %s", err.Error()) 21 | return "" 22 | } 23 | 24 | return result.String 25 | } 26 | 27 | // SettingPut sets a setting string value, inserting if new 28 | func SettingPut(k, v string) error { 29 | _, err := db.Exec(`INSERT INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v) 30 | if err != nil { 31 | logger.Log().Errorf("[db] %s", err.Error()) 32 | } 33 | 34 | return err 35 | } 36 | 37 | // The total deleted message size as an int64 value 38 | func getDeletedSize() uint64 { 39 | var result sql.NullInt64 40 | err := sqlf.From(tenant("settings")). 41 | Select("Value").To(&result). 42 | Where("Key = ?", "DeletedSize"). 43 | Limit(1). 44 | QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) 45 | if err != nil { 46 | logger.Log().Errorf("[db] %s", err.Error()) 47 | return 0 48 | } 49 | 50 | return uint64(result.Int64) 51 | } 52 | 53 | // The total raw non-compressed messages size in bytes of all messages in the database 54 | func totalMessagesSize() uint64 { 55 | var result sql.NullInt64 56 | err := sqlf.From(tenant("mailbox")). 57 | Select("SUM(Size)").To(&result). 58 | QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) 59 | if err != nil { 60 | logger.Log().Errorf("[db] %s", err.Error()) 61 | return 0 62 | } 63 | 64 | return uint64(result.Int64) 65 | } 66 | 67 | // AddDeletedSize will add the value to the DeletedSize setting 68 | func addDeletedSize(v uint64) { 69 | if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil { 70 | logger.Log().Errorf("[db] %s", err.Error()) 71 | } 72 | 73 | if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil { 74 | logger.Log().Errorf("[db] %s", err.Error()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/storage/structs.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "net/mail" 5 | "time" 6 | ) 7 | 8 | // Message data excluding physical attachments 9 | // 10 | // swagger:model Message 11 | type Message struct { 12 | // Database ID 13 | ID string 14 | // Message ID 15 | MessageID string 16 | // From address 17 | From *mail.Address 18 | // To addresses 19 | To []*mail.Address 20 | // Cc addresses 21 | Cc []*mail.Address 22 | // Bcc addresses 23 | Bcc []*mail.Address 24 | // ReplyTo addresses 25 | ReplyTo []*mail.Address 26 | // Return-Path 27 | ReturnPath string 28 | // Message subject 29 | Subject string 30 | // List-Unsubscribe header information 31 | ListUnsubscribe ListUnsubscribe 32 | // Message RFC3339Nano date & time (if set), else date & time received 33 | // ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds) 34 | Date time.Time 35 | // Message tags 36 | Tags []string 37 | // Message body text 38 | Text string 39 | // Message body HTML 40 | HTML string 41 | // Message size in bytes 42 | Size uint64 43 | // Inline message attachments 44 | Inline []Attachment 45 | // Message attachments 46 | Attachments []Attachment 47 | } 48 | 49 | // Attachment struct for inline and attachments 50 | // 51 | // swagger:model Attachment 52 | type Attachment struct { 53 | // Attachment part ID 54 | PartID string 55 | // File name 56 | FileName string 57 | // Content type 58 | ContentType string 59 | // Content ID 60 | ContentID string 61 | // Size in bytes 62 | Size uint64 63 | } 64 | 65 | // MessageSummary struct for frontend messages 66 | // 67 | // swagger:model MessageSummary 68 | type MessageSummary struct { 69 | // Database ID 70 | ID string 71 | // Message ID 72 | MessageID string 73 | // Read status 74 | Read bool 75 | // From address 76 | From *mail.Address 77 | // To address 78 | To []*mail.Address 79 | // Cc addresses 80 | Cc []*mail.Address 81 | // Bcc addresses 82 | Bcc []*mail.Address 83 | // Reply-To address 84 | ReplyTo []*mail.Address 85 | // Email subject 86 | Subject string 87 | // Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds) 88 | Created time.Time 89 | // Message tags 90 | Tags []string 91 | // Message size in bytes (total) 92 | Size uint64 93 | // Whether the message has any attachments 94 | Attachments int 95 | // Message snippet includes up to 250 characters 96 | Snippet string 97 | } 98 | 99 | // MailboxStats struct for quick mailbox total/read lookups 100 | type MailboxStats struct { 101 | Total uint64 102 | Unread uint64 103 | Tags []string 104 | } 105 | 106 | // DBMailSummary struct for storing mail summary 107 | type DBMailSummary struct { 108 | From *mail.Address 109 | To []*mail.Address 110 | Cc []*mail.Address 111 | Bcc []*mail.Address 112 | ReplyTo []*mail.Address 113 | } 114 | 115 | // ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers 116 | // including validation of the link structure 117 | type ListUnsubscribe struct { 118 | // List-Unsubscribe header value 119 | Header string 120 | // Detected links, maximum one email and one HTTP(S) link 121 | Links []string 122 | // Validation errors (if any) 123 | Errors string 124 | // List-Unsubscribe-Post value (if set) 125 | HeaderPost string 126 | } 127 | -------------------------------------------------------------------------------- /internal/storage/tagfilters.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strings" 7 | 8 | "github.com/axllent/mailpit/config" 9 | "github.com/axllent/mailpit/internal/logger" 10 | "github.com/axllent/mailpit/internal/tools" 11 | "github.com/leporo/sqlf" 12 | ) 13 | 14 | // TagFilter struct 15 | type TagFilter struct { 16 | // Match is the user-defined match 17 | Match string 18 | // SQL represents the SQL equivalent of Match 19 | SQL *sqlf.Stmt 20 | // Tags to add on match 21 | Tags []string 22 | } 23 | 24 | var tagFilters = []TagFilter{} 25 | 26 | // LoadTagFilters loads tag filters from the config and pre-generates the SQL query 27 | func LoadTagFilters() { 28 | tagFilters = []TagFilter{} 29 | 30 | for _, t := range config.TagFilters { 31 | match := strings.TrimSpace(t.Match) 32 | if match == "" { 33 | logger.Log().Warnf("[tags] ignoring tag item with missing 'match'") 34 | continue 35 | } 36 | if t.Tags == nil || len(t.Tags) == 0 { 37 | logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array") 38 | continue 39 | } 40 | 41 | validTags := []string{} 42 | for _, tag := range t.Tags { 43 | tagName := tools.CleanTag(tag) 44 | if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 { 45 | logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName) 46 | continue 47 | } 48 | validTags = append(validTags, tagName) 49 | } 50 | 51 | if len(validTags) == 0 { 52 | continue 53 | } 54 | 55 | tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")}) 56 | } 57 | } 58 | 59 | // TagFilterMatches returns a slice of matching tags from a message 60 | func tagFilterMatches(id string) []string { 61 | tags := []string{} 62 | 63 | if len(tagFilters) == 0 { 64 | return tags 65 | } 66 | 67 | for _, f := range tagFilters { 68 | var matchID string 69 | q := f.SQL.Clone().Where("ID = ?", id) 70 | if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) { 71 | var ignore sql.NullString 72 | 73 | if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil { 74 | logger.Log().Errorf("[db] %s", err.Error()) 75 | return 76 | } 77 | }); err != nil { 78 | logger.Log().Errorf("[db] %s", err.Error()) 79 | return tags 80 | } 81 | if matchID == id { 82 | tags = append(tags, f.Tags...) 83 | } 84 | } 85 | 86 | return tags 87 | } 88 | -------------------------------------------------------------------------------- /internal/storage/tags_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/axllent/mailpit/config" 9 | ) 10 | 11 | func TestTags(t *testing.T) { 12 | 13 | for _, tenantID := range []string{"", "MyServer 3", "host.example.com"} { 14 | tenantID = config.DBTenantID(tenantID) 15 | 16 | setup(tenantID) 17 | 18 | if tenantID == "" { 19 | t.Log("Testing tags") 20 | } else { 21 | t.Logf("Testing tags (tenant %s)", tenantID) 22 | } 23 | 24 | ids := []string{} 25 | 26 | for i := 0; i < 10; i++ { 27 | id, err := Store(&testMimeEmail) 28 | if err != nil { 29 | t.Log("error ", err) 30 | t.Fail() 31 | } 32 | ids = append(ids, id) 33 | } 34 | 35 | for i := 0; i < 10; i++ { 36 | if _, err := SetMessageTags(ids[i], []string{fmt.Sprintf("Tag-%d", i)}); err != nil { 37 | t.Log("error ", err) 38 | t.Fail() 39 | } 40 | } 41 | 42 | for i := 0; i < 10; i++ { 43 | message, err := GetMessage(ids[i]) 44 | if err != nil { 45 | t.Log("error ", err) 46 | t.Fail() 47 | } 48 | 49 | if len(message.Tags) != 1 || message.Tags[0] != fmt.Sprintf("Tag-%d", i) { 50 | t.Fatal("Message tags do not match") 51 | } 52 | } 53 | 54 | if err := DeleteAllMessages(); err != nil { 55 | t.Log("error ", err) 56 | t.Fail() 57 | } 58 | 59 | // test 20 tags 60 | id, err := Store(&testMimeEmail) 61 | if err != nil { 62 | t.Log("error ", err) 63 | t.Fail() 64 | } 65 | newTags := []string{} 66 | for i := 0; i < 20; i++ { 67 | // pad number with 0 to ensure they are returned alphabetically 68 | newTags = append(newTags, fmt.Sprintf("AnotherTag %02d", i)) 69 | } 70 | if _, err := SetMessageTags(id, newTags); err != nil { 71 | t.Log("error ", err) 72 | t.Fail() 73 | } 74 | returnedTags := getMessageTags(id) 75 | assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match") 76 | 77 | // remove first tag 78 | if err := deleteMessageTag(id, newTags[0]); err != nil { 79 | t.Log("error ", err) 80 | t.Fail() 81 | } 82 | returnedTags = getMessageTags(id) 83 | assertEqual(t, strings.Join(newTags[1:], "|"), strings.Join(returnedTags, "|"), "Message tags do not match after deleting 1") 84 | 85 | // remove all tags 86 | if err := DeleteAllMessageTags(id); err != nil { 87 | t.Log("error ", err) 88 | t.Fail() 89 | } 90 | returnedTags = getMessageTags(id) 91 | assertEqual(t, "", strings.Join(returnedTags, "|"), "Message tags should be empty") 92 | 93 | // apply the same tag twice 94 | if _, err := SetMessageTags(id, []string{"Duplicate Tag", "Duplicate Tag"}); err != nil { 95 | t.Log("error ", err) 96 | t.Fail() 97 | } 98 | returnedTags = getMessageTags(id) 99 | assertEqual(t, "Duplicate Tag", strings.Join(returnedTags, "|"), "Message tags should be duplicated") 100 | if err := DeleteAllMessageTags(id); err != nil { 101 | t.Log("error ", err) 102 | t.Fail() 103 | } 104 | 105 | // apply tag with invalid characters 106 | if _, err := SetMessageTags(id, []string{"Dirty! \"Tag\""}); err != nil { 107 | t.Log("error ", err) 108 | t.Fail() 109 | } 110 | returnedTags = getMessageTags(id) 111 | assertEqual(t, "Dirty Tag", strings.Join(returnedTags, "|"), "Dirty message tag did not clean as expected") 112 | if err := DeleteAllMessageTags(id); err != nil { 113 | t.Log("error ", err) 114 | t.Fail() 115 | } 116 | 117 | // Check deleted message tags also prune the tags database 118 | allTags := GetAllTags() 119 | assertEqual(t, "", strings.Join(allTags, "|"), "Tags did not delete as expected") 120 | 121 | if err := DeleteAllMessages(); err != nil { 122 | t.Log("error ", err) 123 | t.Fail() 124 | } 125 | 126 | // test 20 tags 127 | id, err = Store(&testTagEmail) 128 | if err != nil { 129 | t.Log("error ", err) 130 | t.Fail() 131 | } 132 | 133 | returnedTags = getMessageTags(id) 134 | assertEqual(t, "BccTag|CcTag|FromFag|ToTag|X-tag1|X-tag2", strings.Join(returnedTags, "|"), "Tags not detected correctly") 135 | if err := DeleteAllMessageTags(id); err != nil { 136 | t.Log("error ", err) 137 | t.Fail() 138 | } 139 | 140 | Close() 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /internal/storage/testdata/tags.eml: -------------------------------------------------------------------------------- 1 | Date: Wed, 27 Jul 2022 15:44:41 +1200 2 | From: Sender Smith 3 | To: Recipient Ross 4 | Cc: Recipient Ross 5 | Bcc: 6 | Subject: Plain text message 7 | X-Tags: X-tag1, X-tag2 8 | Message-ID: <20220727034441.7za34h6ljuzfpmj3@localhost.localhost> 9 | MIME-Version: 1.0 10 | Content-Type: text/plain; charset=us-ascii 11 | Content-Disposition: inline 12 | 13 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia, 14 | fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non 15 | hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat, 16 | mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at 17 | posuere libero. Fusce a gravida nibh. Nulla ac odio ex. 18 | 19 | Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus. 20 | Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis 21 | sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue 22 | ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis 23 | eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet 24 | orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet. 25 | Pellentesque enim nibh, varius at ante id, consequat posuere ante. 26 | 27 | Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra 28 | vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec 29 | et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque 30 | condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet 31 | tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus 32 | massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor 33 | et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur 34 | nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum 35 | sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel 36 | ipsum. Cras condimentum posuere vulputate. 37 | 38 | Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est 39 | augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget 40 | justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales. 41 | Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero 42 | venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in 43 | nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt. 44 | Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum 45 | vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse 46 | mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu 47 | arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis 48 | lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget 49 | lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus. 50 | -------------------------------------------------------------------------------- /internal/storage/testing.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/axllent/mailpit/config" 9 | "github.com/axllent/mailpit/internal/logger" 10 | ) 11 | 12 | var ( 13 | testTextEmail []byte 14 | testTagEmail []byte 15 | testMimeEmail []byte 16 | testRuns = 100 17 | ) 18 | 19 | func setup(tenantID string) { 20 | logger.NoLogging = true 21 | config.MaxMessages = 0 22 | config.Database = os.Getenv("MP_DATABASE") 23 | config.TenantID = config.DBTenantID(tenantID) 24 | 25 | if err := InitDB(); err != nil { 26 | panic(err) 27 | } 28 | 29 | var err error 30 | 31 | // ensure DB is empty 32 | if err := DeleteAllMessages(); err != nil { 33 | panic(err) 34 | } 35 | 36 | testTextEmail, err = os.ReadFile("testdata/plain-text.eml") 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | testTagEmail, err = os.ReadFile("testdata/tags.eml") 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | testMimeEmail, err = os.ReadFile("testdata/mime-attachment.eml") 47 | if err != nil { 48 | panic(err) 49 | } 50 | } 51 | 52 | func assertEqual(t *testing.T, a interface{}, b interface{}, message string) { 53 | if a == b { 54 | return 55 | } 56 | message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b) 57 | t.Fatal(message) 58 | } 59 | 60 | func assertEqualStats(t *testing.T, total int, unread int) { 61 | s := StatsGet() 62 | if uint64(total) != s.Total { 63 | t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total) 64 | } 65 | 66 | if uint64(unread) != s.Unread { 67 | t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/storage/utils.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "net/mail" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/axllent/mailpit/internal/html2text" 11 | "github.com/axllent/mailpit/internal/logger" 12 | "github.com/jhillyerd/enmime/v2" 13 | ) 14 | 15 | var ( 16 | // for stats to prevent import cycle 17 | mu sync.RWMutex 18 | // StatsDeleted for counting the number of messages deleted 19 | StatsDeleted uint64 20 | ) 21 | 22 | // AddTempFile adds a file to the slice of files to delete on exit 23 | func AddTempFile(s string) { 24 | temporaryFiles = append(temporaryFiles, s) 25 | } 26 | 27 | // DeleteTempFiles will delete files added via AddTempFiles 28 | func deleteTempFiles() { 29 | for _, f := range temporaryFiles { 30 | if err := os.Remove(f); err == nil { 31 | logger.Log().Debugf("removed temporary file: %s", f) 32 | } 33 | } 34 | } 35 | 36 | // Return a header field as a []*mail.Address, or "null" is not found/empty 37 | func addressToSlice(env *enmime.Envelope, key string) []*mail.Address { 38 | data, err := env.AddressList(key) 39 | if err != nil || data == nil { 40 | return []*mail.Address{} 41 | } 42 | 43 | return data 44 | } 45 | 46 | // Generate the search text based on some header fields (to, from, subject etc) 47 | // and either the stripped HTML body (if exists) or text body 48 | func createSearchText(env *enmime.Envelope) string { 49 | var b strings.Builder 50 | 51 | b.WriteString(env.GetHeader("From") + " ") 52 | b.WriteString(env.GetHeader("Subject") + " ") 53 | b.WriteString(env.GetHeader("To") + " ") 54 | b.WriteString(env.GetHeader("Cc") + " ") 55 | b.WriteString(env.GetHeader("Bcc") + " ") 56 | b.WriteString(env.GetHeader("Reply-To") + " ") 57 | b.WriteString(env.GetHeader("Return-Path") + " ") 58 | 59 | h := html2text.Strip(env.HTML, true) 60 | if h != "" { 61 | b.WriteString(h + " ") 62 | } else { 63 | b.WriteString(env.Text + " ") 64 | } 65 | // add attachment filenames 66 | for _, a := range env.Attachments { 67 | b.WriteString(a.FileName + " ") 68 | } 69 | 70 | d := cleanString(b.String()) 71 | 72 | return d 73 | } 74 | 75 | // CleanString removes unwanted characters from stored search text and search queries 76 | func cleanString(str string) string { 77 | // replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184 78 | str = strings.ReplaceAll(str, string('\uFEFF'), " ") 79 | 80 | // remove/replace new lines 81 | re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`) 82 | str = re.ReplaceAllString(str, " ") 83 | 84 | // remove duplicate whitespace and trim 85 | return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " ")) 86 | } 87 | 88 | // LogMessagesDeleted logs the number of messages deleted 89 | func logMessagesDeleted(n int) { 90 | mu.Lock() 91 | StatsDeleted = StatsDeleted + uint64(n) 92 | mu.Unlock() 93 | } 94 | 95 | // IsFile returns whether a path is a file 96 | func isFile(path string) bool { 97 | info, err := os.Stat(path) 98 | if os.IsNotExist(err) || !info.Mode().IsRegular() { 99 | return false 100 | } 101 | 102 | return true 103 | } 104 | 105 | // Convert `%` to `%%` for SQL searches 106 | func escPercentChar(s string) string { 107 | return strings.ReplaceAll(s, "%", "%%") 108 | } 109 | -------------------------------------------------------------------------------- /internal/tools/argsparser.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import "strings" 4 | 5 | // ArgsParser will split a string by new words and quotes phrases 6 | func ArgsParser(s string) []string { 7 | args := []string{} 8 | sb := &strings.Builder{} 9 | quoted := false 10 | for _, r := range s { 11 | if r == '"' { 12 | quoted = !quoted 13 | sb.WriteRune(r) // keep '"' otherwise comment this line 14 | } else if !quoted && r == ' ' { 15 | v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", "")) 16 | if v != "" { 17 | args = append(args, v) 18 | } 19 | sb.Reset() 20 | } else { 21 | sb.WriteRune(r) 22 | } 23 | } 24 | if sb.Len() > 0 { 25 | v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", "")) 26 | if v != "" { 27 | args = append(args, v) 28 | } 29 | } 30 | 31 | return args 32 | } 33 | -------------------------------------------------------------------------------- /internal/tools/fs.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // IsFile returns whether a file exists and is readable 9 | func IsFile(path string) bool { 10 | f, err := os.Open(filepath.Clean(path)) 11 | defer f.Close() 12 | return err == nil 13 | } 14 | 15 | // IsDir returns whether a path is a directory 16 | func IsDir(path string) bool { 17 | info, err := os.Stat(path) 18 | if err != nil || os.IsNotExist(err) || !info.IsDir() { 19 | return false 20 | } 21 | 22 | return true 23 | } 24 | -------------------------------------------------------------------------------- /internal/tools/html.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.org/x/net/html" 7 | ) 8 | 9 | // GetHTMLAttributeVal returns the value of an HTML Attribute, else an error. 10 | // Returns a blank value if the attribute is set but empty. 11 | func GetHTMLAttributeVal(e *html.Node, key string) (string, error) { 12 | for _, a := range e.Attr { 13 | if a.Key == key { 14 | return a.Val, nil 15 | } 16 | } 17 | 18 | return "", fmt.Errorf("%s not found", key) 19 | } 20 | 21 | // SetHTMLAttributeVal sets an attribute on a node. 22 | func SetHTMLAttributeVal(n *html.Node, key, val string) { 23 | for i := range n.Attr { 24 | a := &n.Attr[i] 25 | if a.Key == key { 26 | a.Val = val 27 | return 28 | } 29 | } 30 | n.Attr = append(n.Attr, html.Attribute{ 31 | Key: key, 32 | Val: val, 33 | }) 34 | } 35 | 36 | // WalkHTML traverses the entire HTML tree and calls fn on each node. 37 | func WalkHTML(n *html.Node, fn func(*html.Node)) { 38 | if n == nil { 39 | return 40 | } 41 | 42 | fn(n) 43 | 44 | // Each node has a pointer to its first child and next sibling. To traverse 45 | // all children of a node, we need to start from its first child and then 46 | // traverse the next sibling until nil. 47 | for c := n.FirstChild; c != nil; c = c.NextSibling { 48 | WalkHTML(c, fn) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/tools/listunsubscribeparser.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | // ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return 11 | // a slide of addresses (mail & URLs) 12 | func ListUnsubscribeParser(v string) ([]string, error) { 13 | var results = []string{} 14 | var re = regexp.MustCompile(`(?mU)<(.*)>`) 15 | var reJoins = regexp.MustCompile(`(?imUs)>(.*)<`) 16 | var reValidJoinChars = regexp.MustCompile(`(?imUs)^(\s+)?,(\s+)?$`) 17 | var reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`) 18 | var reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`) 19 | var reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`) 20 | var reSpaces = regexp.MustCompile(`\s`) 21 | var reComments = regexp.MustCompile(`(?mUs)\(.*\)`) 22 | var hasMailTo bool 23 | var hasHTTP bool 24 | 25 | v = strings.TrimSpace(v) 26 | 27 | comments := reComments.FindAllStringSubmatch(v, -1) 28 | for _, c := range comments { 29 | // strip comments 30 | v = strings.Replace(v, c[0], "", -1) 31 | v = strings.TrimSpace(v) 32 | } 33 | 34 | if !re.MatchString(v) { 35 | return results, fmt.Errorf("\"%s\" no valid unsubscribe links found", v) 36 | } 37 | 38 | errors := []string{} 39 | 40 | if !reWrapper.MatchString(v) { 41 | return results, fmt.Errorf("\"%s\" should be enclosed in <>", v) 42 | } 43 | 44 | matches := re.FindAllStringSubmatch(v, -1) 45 | 46 | if len(matches) > 2 { 47 | errors = append(errors, fmt.Sprintf("\"%s\" should include a maximum of one email and one HTTP link", v)) 48 | } else { 49 | splits := reJoins.FindAllStringSubmatch(v, -1) 50 | for _, g := range splits { 51 | if !reValidJoinChars.MatchString(g[1]) { 52 | return results, fmt.Errorf("\"%s\" <> should be split with a comma and optional spaces", v) 53 | } 54 | } 55 | 56 | for _, m := range matches { 57 | r := m[1] 58 | if reSpaces.MatchString(r) { 59 | errors = append(errors, fmt.Sprintf("\"%s\" should not contain spaces", r)) 60 | continue 61 | } 62 | 63 | if reMailTo.MatchString(r) { 64 | if hasMailTo { 65 | errors = append(errors, fmt.Sprintf("\"%s\" should only contain one mailto:", r)) 66 | continue 67 | } 68 | 69 | hasMailTo = true 70 | } else if reHTTP.MatchString(r) { 71 | if hasHTTP { 72 | errors = append(errors, fmt.Sprintf("\"%s\" should only contain one HTTP link", r)) 73 | continue 74 | } 75 | 76 | hasHTTP = true 77 | 78 | } else { 79 | errors = append(errors, fmt.Sprintf("\"%s\" should start with either http(s):// or mailto:", r)) 80 | continue 81 | } 82 | 83 | _, err := url.ParseRequestURI(r) 84 | if err != nil { 85 | errors = append(errors, err.Error()) 86 | continue 87 | } 88 | 89 | results = append(results, r) 90 | } 91 | } 92 | 93 | var err error 94 | if len(errors) > 0 { 95 | err = fmt.Errorf("%s", strings.Join(errors, ", ")) 96 | } 97 | 98 | return results, err 99 | } 100 | -------------------------------------------------------------------------------- /internal/tools/snippets.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/axllent/mailpit/internal/html2text" 8 | ) 9 | 10 | // CreateSnippet returns a message snippet. It will use the HTML version (if it exists) 11 | // otherwise the text version. 12 | func CreateSnippet(text, html string) string { 13 | text = strings.TrimSpace(text) 14 | html = strings.TrimSpace(html) 15 | limit := 200 16 | spaceRe := regexp.MustCompile(`\s+`) 17 | 18 | if text == "" && html == "" { 19 | return "" 20 | } 21 | 22 | if html != "" { 23 | data := html2text.Strip(html, false) 24 | 25 | if len(data) <= limit { 26 | return data 27 | } 28 | 29 | return truncate(data, limit) + "..." 30 | } 31 | 32 | if text != "" { 33 | // replace \uFEFF with space, see https://github.com/golang/go/issues/42274#issuecomment-1017258184 34 | text = strings.ReplaceAll(text, string('\uFEFF'), " ") 35 | text = strings.TrimSpace(spaceRe.ReplaceAllString(text, " ")) 36 | if len(text) <= limit { 37 | return text 38 | } 39 | 40 | return truncate(text, limit) + "..." 41 | } 42 | 43 | return "" 44 | } 45 | 46 | // Truncate a string allowing for multi-byte encoding. 47 | // Shamelessly borrowed from Tailscale. 48 | // See https://github.com/tailscale/tailscale/blob/main/util/truncate/truncate.go 49 | func truncate(s string, n int) string { 50 | if n >= len(s) { 51 | return s 52 | } 53 | 54 | // Back up until we find the beginning of a UTF-8 encoding. 55 | for n > 0 && s[n-1]&0xc0 == 0x80 { // 0x10... is a continuation byte 56 | n-- 57 | } 58 | 59 | // If we're at the beginning of a multi-byte encoding, back up one more to 60 | // skip it. It's possible the value was already complete, but it's simpler 61 | // if we only have to check in one direction. 62 | // 63 | // Otherwise, we have a single-byte code (0x00... or 0x01...). 64 | if n > 0 && s[n-1]&0xc0 == 0xc0 { // 0x11... starts a multibyte encoding 65 | n-- 66 | } 67 | 68 | return s[:n] 69 | } 70 | -------------------------------------------------------------------------------- /internal/tools/tags.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "golang.org/x/text/cases" 8 | "golang.org/x/text/language" 9 | ) 10 | 11 | var ( 12 | // Invalid tag characters regex 13 | tagsInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9\-\ \_\.]`) 14 | 15 | // Regex to catch multiple spaces 16 | multiSpaceRe = regexp.MustCompile(`(\s+)`) 17 | 18 | // TagsTitleCase enforces TitleCase on all tags 19 | TagsTitleCase bool 20 | ) 21 | 22 | // CleanTag returns a clean tag, trimming whitespace and replacing invalid characters 23 | func CleanTag(s string) string { 24 | return strings.TrimSpace( 25 | multiSpaceRe.ReplaceAllString( 26 | tagsInvalidChars.ReplaceAllString(s, " "), 27 | " ", 28 | ), 29 | ) 30 | } 31 | 32 | // SetTagCasing returns the slice of tags, title-casing if set 33 | func SetTagCasing(s []string) []string { 34 | if !TagsTitleCase { 35 | return s 36 | } 37 | 38 | titleTags := []string{} 39 | 40 | c := cases.Title(language.Und, cases.NoLower) 41 | 42 | for _, t := range s { 43 | titleTags = append(titleTags, c.String(t)) 44 | } 45 | 46 | return titleTags 47 | } 48 | -------------------------------------------------------------------------------- /internal/tools/unixsocket.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "net" 7 | "os" 8 | "path" 9 | "regexp" 10 | "strconv" 11 | ) 12 | 13 | // UnixSocket returns a path and a FileMode if the address is in 14 | // the format of unix:: 15 | func UnixSocket(address string) (string, fs.FileMode, bool) { 16 | re := regexp.MustCompile(`^unix:(.*):(\d\d\d\d?)$`) 17 | 18 | var f fs.FileMode 19 | 20 | if !re.MatchString(address) { 21 | return "", f, false 22 | } 23 | 24 | m := re.FindAllStringSubmatch(address, 1) 25 | 26 | modeVal, err := strconv.ParseUint(m[0][2], 8, 32) 27 | 28 | if err != nil { 29 | return "", f, false 30 | } 31 | 32 | return path.Clean(m[0][1]), fs.FileMode(modeVal), true 33 | } 34 | 35 | // PrepareSocket returns an error if an active socket file already exists 36 | func PrepareSocket(address string) error { 37 | address = path.Clean(address) 38 | if _, err := os.Stat(address); os.IsNotExist(err) { 39 | // does not exist, OK 40 | return nil 41 | } 42 | 43 | if _, err := net.Dial("unix", address); err == nil { 44 | // socket is listening 45 | return fmt.Errorf("socket already in use: %s", address) 46 | } 47 | 48 | return os.Remove(address) 49 | } 50 | -------------------------------------------------------------------------------- /internal/tools/utils.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // Plural returns a singular or plural of a word together with the total 10 | func Plural(total int, singular, plural string) string { 11 | if total == 1 { 12 | return fmt.Sprintf("%d %s", total, singular) 13 | } 14 | 15 | return fmt.Sprintf("%d %s", total, plural) 16 | } 17 | 18 | // InArray tests if a string is within an array. It is not case sensitive. 19 | func InArray(k string, arr []string) bool { 20 | for _, v := range arr { 21 | if strings.EqualFold(v, k) { 22 | return true 23 | } 24 | } 25 | 26 | return false 27 | } 28 | 29 | // Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces 30 | func Normalize(s string) string { 31 | nlRe := regexp.MustCompile(`\r?\r`) 32 | re := regexp.MustCompile(`\s+`) 33 | 34 | s = nlRe.ReplaceAllString(s, " ") 35 | s = re.ReplaceAllString(s, " ") 36 | 37 | return strings.TrimSpace(s) 38 | } 39 | -------------------------------------------------------------------------------- /internal/updater/unzip.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // Unzip will decompress a zip archive, moving all files and folders 13 | // within the zip file (src) to an output directory (dest). 14 | func Unzip(src string, dest string) ([]string, error) { 15 | 16 | var filenames []string 17 | 18 | r, err := zip.OpenReader(src) 19 | if err != nil { 20 | return filenames, err 21 | } 22 | defer r.Close() 23 | 24 | for _, f := range r.File { 25 | 26 | // Store filename/path for returning and using later on 27 | fpath := filepath.Join(dest, filepath.Clean(f.Name)) 28 | 29 | // Check for ZipSlip. More Info: http://bit.ly/2MsjAWE 30 | if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { 31 | return filenames, fmt.Errorf("%s: illegal file path", fpath) 32 | } 33 | 34 | filenames = append(filenames, fpath) 35 | 36 | if f.FileInfo().IsDir() { 37 | // Make Folder 38 | if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil { 39 | return filenames, err 40 | } 41 | continue 42 | } 43 | 44 | // Make File 45 | if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil { 46 | return filenames, err 47 | } 48 | 49 | outFile, err := os.OpenFile(filepath.Clean(fpath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 50 | if err != nil { 51 | return filenames, err 52 | } 53 | 54 | rc, err := f.Open() 55 | if err != nil { 56 | return filenames, err 57 | } 58 | 59 | _, err = io.Copy(outFile, rc) // #nosec - file is streamed from zip to file 60 | 61 | // Close the file without defer to close before next iteration of loop 62 | if err := outFile.Close(); err != nil { 63 | return filenames, err 64 | } 65 | 66 | if err := rc.Close(); err != nil { 67 | return filenames, err 68 | } 69 | 70 | if err != nil { 71 | return filenames, err 72 | } 73 | } 74 | 75 | return filenames, nil 76 | } 77 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/axllent/mailpit/cmd" 9 | sendmail "github.com/axllent/mailpit/sendmail/cmd" 10 | ) 11 | 12 | func main() { 13 | exec, err := os.Executable() 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | // running directly 19 | if normalize(filepath.Base(exec)) == normalize(filepath.Base(os.Args[0])) || 20 | !strings.Contains(filepath.Base(os.Args[0]), "sendmail") { 21 | cmd.Execute() 22 | } else { 23 | // symlinked as "*sendmail*" 24 | sendmail.Run() 25 | } 26 | } 27 | 28 | // Normalize returns a lowercase string stripped of the file extension (if exists). 29 | // Used for detecting Windows commands which ignores letter casing and `.exe`. 30 | // eg: "MaIlpIT.Exe" returns "mailpit" 31 | func normalize(s string) string { 32 | s = strings.ToLower(s) 33 | 34 | return strings.TrimSuffix(s, filepath.Ext(s)) 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailpit", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "MINIFY=true node esbuild.config.mjs", 7 | "watch": "WATCH=true node esbuild.config.mjs", 8 | "package": "MINIFY=true node esbuild.config.mjs", 9 | "update-caniemail": "wget -O internal/htmlcheck/caniemail-data.json https://www.caniemail.com/api/data.json" 10 | }, 11 | "dependencies": { 12 | "axios": "^1.2.1", 13 | "bootstrap": "^5.2.0", 14 | "bootstrap-icons": "^1.9.1", 15 | "bootstrap5-tags": "^1.6.1", 16 | "color-hash": "^2.0.2", 17 | "dayjs": "^1.11.10", 18 | "dompurify": "^3.1.6", 19 | "highlight.js": "^11.11.1", 20 | "ical.js": "^2.0.1", 21 | "mitt": "^3.0.1", 22 | "modern-screenshot": "^4.4.30", 23 | "rapidoc": "^9.3.4", 24 | "timezones-list": "^3.0.3", 25 | "vue": "^3.2.13", 26 | "vue-css-donut-chart": "^2.0.0", 27 | "vue-router": "^4.2.4" 28 | }, 29 | "devDependencies": { 30 | "@popperjs/core": "^2.11.5", 31 | "@types/bootstrap": "^5.2.7", 32 | "@types/tinycon": "^0.6.3", 33 | "@vue/compiler-sfc": "^3.2.37", 34 | "esbuild": "^0.25.0", 35 | "esbuild-plugin-vue-next": "^0.1.4", 36 | "esbuild-sass-plugin": "^3.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sendmail/cmd/smtp.go: -------------------------------------------------------------------------------- 1 | // Package cmd is a wrapper library to send mail 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "net" 7 | "net/mail" 8 | "net/smtp" 9 | "os" 10 | 11 | "github.com/axllent/mailpit/internal/logger" 12 | ) 13 | 14 | // Send is a wrapper for smtp.SendMail() which also supports sending via unix sockets. 15 | // Unix sockets must be set as unix:/path/to/socket 16 | // It does not support authentication. 17 | func Send(addr string, from string, to []string, msg []byte) error { 18 | socketPath, isSocket := socketAddress(addr) 19 | 20 | fromAddress, err := mail.ParseAddress(from) 21 | if err != nil { 22 | return fmt.Errorf("invalid from address: %s", from) 23 | } 24 | 25 | if len(to) == 0 { 26 | return fmt.Errorf("no To addresses specified") 27 | } 28 | 29 | if !isSocket { 30 | return smtp.SendMail(addr, nil, fromAddress.Address, to, msg) 31 | } 32 | 33 | conn, err := net.Dial("unix", socketPath) 34 | if err != nil { 35 | return fmt.Errorf("error connecting to %s", addr) 36 | } 37 | 38 | client, err := smtp.NewClient(conn, "") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // Set the sender 44 | if err := client.Mail(fromAddress.Address); err != nil { 45 | fmt.Fprintln(os.Stderr, "error sending mail") 46 | logger.Log().Fatal(err) 47 | } 48 | 49 | // Set the recipient 50 | for _, a := range to { 51 | if err := client.Rcpt(a); err != nil { 52 | return err 53 | } 54 | } 55 | 56 | wc, err := client.Data() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | _, err = wc.Write(msg) 62 | if err != nil { 63 | return err 64 | } 65 | err = wc.Close() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /sendmail/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/axllent/mailpit/sendmail/cmd" 4 | 5 | func main() { 6 | cmd.Run() 7 | } 8 | -------------------------------------------------------------------------------- /server/apiv1/api.go: -------------------------------------------------------------------------------- 1 | // Package apiv1 handles all the API responses 2 | package apiv1 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/araddon/dateparse" 11 | "github.com/axllent/mailpit/config" 12 | "github.com/axllent/mailpit/internal/logger" 13 | ) 14 | 15 | // FourOFour returns a basic 404 message 16 | func fourOFour(w http.ResponseWriter) { 17 | w.Header().Set("Referrer-Policy", "no-referrer") 18 | w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) 19 | w.WriteHeader(http.StatusNotFound) 20 | w.Header().Set("Content-Type", "text/plain") 21 | fmt.Fprint(w, "404 page not found") 22 | } 23 | 24 | // HTTPError returns a basic error message (400 response) 25 | func httpError(w http.ResponseWriter, msg string) { 26 | w.Header().Set("Referrer-Policy", "no-referrer") 27 | w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) 28 | w.WriteHeader(http.StatusBadRequest) 29 | w.Header().Set("Content-Type", "text/plain") 30 | fmt.Fprint(w, msg) 31 | } 32 | 33 | // httpJSONError returns a basic error message (400 response) in JSON format 34 | func httpJSONError(w http.ResponseWriter, msg string) { 35 | w.Header().Set("Referrer-Policy", "no-referrer") 36 | w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) 37 | w.WriteHeader(http.StatusBadRequest) 38 | e := JSONErrorMessage{ 39 | Error: msg, 40 | } 41 | 42 | w.Header().Add("Content-Type", "application/json") 43 | if err := json.NewEncoder(w).Encode(e); err != nil { 44 | httpError(w, err.Error()) 45 | } 46 | } 47 | 48 | // Get the start and limit based on query params. Defaults to 0, 50 49 | func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) { 50 | start = 0 51 | limit = 50 52 | beforeTS = 0 // timestamp 53 | 54 | s := req.URL.Query().Get("start") 55 | if n, err := strconv.Atoi(s); err == nil && n > 0 { 56 | start = n 57 | } 58 | 59 | l := req.URL.Query().Get("limit") 60 | if n, err := strconv.Atoi(l); err == nil && n > -1 { 61 | limit = n 62 | } 63 | 64 | b := req.URL.Query().Get("before") 65 | if b != "" { 66 | t, err := dateparse.ParseLocal(b) 67 | if err != nil { 68 | logger.Log().Warnf("ignoring invalid before: date \"%s\"", b) 69 | } else { 70 | beforeTS = t.UnixMilli() 71 | } 72 | } 73 | 74 | return start, beforeTS, limit 75 | } 76 | 77 | // GetOptions returns a blank response 78 | func GetOptions(w http.ResponseWriter, _ *http.Request) { 79 | 80 | w.Header().Set("Content-Type", "text/plain") 81 | _, _ = w.Write([]byte("")) 82 | } 83 | -------------------------------------------------------------------------------- /server/apiv1/application.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/axllent/mailpit/config" 9 | "github.com/axllent/mailpit/internal/smtpd/chaos" 10 | "github.com/axllent/mailpit/internal/stats" 11 | ) 12 | 13 | // Application information 14 | // swagger:response AppInfoResponse 15 | type appInfoResponse struct { 16 | // Application information 17 | // 18 | // in: body 19 | Body stats.AppInformation 20 | } 21 | 22 | // AppInfo returns some basic details about the running app, and latest release. 23 | func AppInfo(w http.ResponseWriter, _ *http.Request) { 24 | // swagger:route GET /api/v1/info application AppInformation 25 | // 26 | // # Get application information 27 | // 28 | // Returns basic runtime information, message totals and latest release version. 29 | // 30 | // Produces: 31 | // - application/json 32 | // 33 | // Schemes: http, https 34 | // 35 | // Responses: 36 | // 200: AppInfoResponse 37 | // 400: ErrorResponse 38 | 39 | w.Header().Add("Content-Type", "application/json") 40 | if err := json.NewEncoder(w).Encode(stats.Load()); err != nil { 41 | httpError(w, err.Error()) 42 | } 43 | } 44 | 45 | // Response includes global web UI settings 46 | // 47 | // swagger:model WebUIConfiguration 48 | type webUIConfiguration struct { 49 | // Optional label to identify this Mailpit instance 50 | Label string 51 | // Message Relay information 52 | MessageRelay struct { 53 | // Whether message relaying (release) is enabled 54 | Enabled bool 55 | // The configured SMTP server address 56 | SMTPServer string 57 | // Enforced Return-Path (if set) for relay bounces 58 | ReturnPath string 59 | // Only allow relaying to these recipients (regex) 60 | AllowedRecipients string 61 | // Block relaying to these recipients (regex) 62 | BlockedRecipients string 63 | // Overrides the "From" address for all relayed messages 64 | OverrideFrom string 65 | // DEPRECATED 2024/03/12 66 | // swagger:ignore 67 | RecipientAllowlist string 68 | } 69 | 70 | // Whether SpamAssassin is enabled 71 | SpamAssassin bool 72 | 73 | // Whether Chaos support is enabled at runtime 74 | ChaosEnabled bool 75 | 76 | // Whether messages with duplicate IDs are ignored 77 | DuplicatesIgnored bool 78 | 79 | // Whether the delete button should be hidden 80 | HideDeleteAllButton bool 81 | } 82 | 83 | // Web UI configuration response 84 | // swagger:response WebUIConfigurationResponse 85 | type webUIConfigurationResponse struct { 86 | // Web UI configuration settings 87 | // 88 | // in: body 89 | Body webUIConfiguration 90 | } 91 | 92 | // WebUIConfig returns configuration settings for the web UI. 93 | func WebUIConfig(w http.ResponseWriter, _ *http.Request) { 94 | // swagger:route GET /api/v1/webui application WebUIConfiguration 95 | // 96 | // # Get web UI configuration 97 | // 98 | // Returns configuration settings for the web UI. 99 | // Intended for web UI only! 100 | // 101 | // Produces: 102 | // - application/json 103 | // 104 | // Schemes: http, https 105 | // 106 | // Responses: 107 | // 200: WebUIConfigurationResponse 108 | // 400: ErrorResponse 109 | 110 | conf := webUIConfiguration{} 111 | 112 | conf.Label = config.Label 113 | conf.MessageRelay.Enabled = config.ReleaseEnabled 114 | if config.ReleaseEnabled { 115 | conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port) 116 | conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath 117 | conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients 118 | conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients 119 | conf.MessageRelay.OverrideFrom = config.SMTPRelayConfig.OverrideFrom 120 | // DEPRECATED 2024/03/12 121 | conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients 122 | } 123 | 124 | conf.SpamAssassin = config.EnableSpamAssassin != "" 125 | conf.ChaosEnabled = chaos.Enabled 126 | conf.DuplicatesIgnored = config.IgnoreDuplicateIDs 127 | conf.HideDeleteAllButton = config.HideDeleteAllButton 128 | 129 | w.Header().Add("Content-Type", "application/json") 130 | if err := json.NewEncoder(w).Encode(conf); err != nil { 131 | httpError(w, err.Error()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /server/apiv1/chaos.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/axllent/mailpit/internal/smtpd/chaos" 8 | ) 9 | 10 | // ChaosTriggers are the Chaos triggers 11 | type ChaosTriggers chaos.Triggers 12 | 13 | // Response for the Chaos triggers configuration 14 | // swagger:response ChaosResponse 15 | type chaosResponse struct { 16 | // The current Chaos triggers 17 | // 18 | // in: body 19 | Body ChaosTriggers 20 | } 21 | 22 | // GetChaos returns the current Chaos triggers 23 | func GetChaos(w http.ResponseWriter, _ *http.Request) { 24 | // swagger:route GET /api/v1/chaos testing getChaos 25 | // 26 | // # Get Chaos triggers 27 | // 28 | // Returns the current Chaos triggers configuration. 29 | // This API route will return an error if Chaos is not enabled at runtime. 30 | // 31 | // Produces: 32 | // - application/json 33 | // 34 | // Schemes: http, https 35 | // 36 | // Responses: 37 | // 200: ChaosResponse 38 | // 400: ErrorResponse 39 | 40 | if !chaos.Enabled { 41 | httpError(w, "Chaos is not enabled") 42 | return 43 | } 44 | 45 | conf := chaos.Config 46 | 47 | w.Header().Add("Content-Type", "application/json") 48 | if err := json.NewEncoder(w).Encode(conf); err != nil { 49 | httpError(w, err.Error()) 50 | } 51 | } 52 | 53 | // swagger:parameters setChaosParams 54 | type setChaosParams struct { 55 | // in: body 56 | Body ChaosTriggers 57 | } 58 | 59 | // SetChaos sets the Chaos configuration. 60 | func SetChaos(w http.ResponseWriter, r *http.Request) { 61 | // swagger:route PUT /api/v1/chaos testing setChaosParams 62 | // 63 | // # Set Chaos triggers 64 | // 65 | // Set the Chaos triggers configuration and return the updated values. 66 | // This API route will return an error if Chaos is not enabled at runtime. 67 | // 68 | // If any triggers are omitted from the request, then those are reset to their 69 | // default values with a 0% probability (ie: disabled). 70 | // Setting a blank `{}` will reset all triggers to their default values. 71 | // 72 | // Consumes: 73 | // - application/json 74 | // 75 | // Produces: 76 | // - application/json 77 | // 78 | // Schemes: http, https 79 | // 80 | // Responses: 81 | // 200: ChaosResponse 82 | // 400: ErrorResponse 83 | 84 | if !chaos.Enabled { 85 | httpError(w, "Chaos is not enabled") 86 | return 87 | } 88 | 89 | data := chaos.Triggers{} 90 | 91 | decoder := json.NewDecoder(r.Body) 92 | 93 | err := decoder.Decode(&data) 94 | if err != nil { 95 | httpError(w, err.Error()) 96 | return 97 | } 98 | 99 | if err := chaos.SetFromStruct(data); err != nil { 100 | httpError(w, err.Error()) 101 | return 102 | } 103 | 104 | conf := chaos.Config 105 | 106 | w.Header().Add("Content-Type", "application/json") 107 | if err := json.NewEncoder(w).Encode(conf); err != nil { 108 | httpError(w, err.Error()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /server/apiv1/structs.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | import ( 4 | "github.com/axllent/mailpit/internal/storage" 5 | ) 6 | 7 | // The following structs & aliases are provided for easy import 8 | // and understanding of the JSON structure. 9 | 10 | // MessageSummary - summary of a single message 11 | type MessageSummary = storage.MessageSummary 12 | 13 | // Message data 14 | type Message = storage.Message 15 | 16 | // Attachment summary 17 | type Attachment = storage.Attachment 18 | -------------------------------------------------------------------------------- /server/apiv1/swagger-config.yml: -------------------------------------------------------------------------------- 1 | consumes: 2 | - application/json 3 | info: 4 | description: |- 5 | OpenAPI 2.0 documentation for [Mailpit](https://github.com/axllent/mailpit). 6 | title: Mailpit API 7 | contact: 8 | name: GitHub 9 | url: https://github.com/axllent/mailpit 10 | license: 11 | name: MIT license 12 | url: https://github.com/axllent/mailpit/blob/develop/LICENSE 13 | version: "v1" 14 | paths: {} 15 | produces: 16 | - application/json 17 | schemes: 18 | - http 19 | swagger: "2.0" 20 | -------------------------------------------------------------------------------- /server/apiv1/swagger.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | // These structs are for the purpose of defining swagger HTTP parameters & responses 4 | 5 | // Binary data response which inherits the attachment's content type. 6 | // swagger:response BinaryResponse 7 | type binaryResponse string 8 | 9 | // Plain text response 10 | // swagger:response TextResponse 11 | type textResponse string 12 | 13 | // HTML response 14 | // swagger:response HTMLResponse 15 | type htmlResponse string 16 | 17 | // Server error will return with a 400 status code 18 | // with the error message in the body 19 | // swagger:response ErrorResponse 20 | type errorResponse string 21 | 22 | // Not found error will return a 404 status code 23 | // swagger:response NotFoundResponse 24 | type notFoundResponse string 25 | 26 | // Plain text "ok" response 27 | // swagger:response OKResponse 28 | type okResponse string 29 | 30 | // Plain JSON array response 31 | // swagger:response ArrayResponse 32 | type arrayResponse []string 33 | 34 | // JSON error response 35 | // swagger:response jsonErrorResponse 36 | type jsonErrorResponse struct { 37 | // A JSON-encoded error response 38 | // 39 | // in: body 40 | Body JSONErrorMessage 41 | } 42 | -------------------------------------------------------------------------------- /server/apiv1/tags.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/axllent/mailpit/internal/storage" 8 | "github.com/axllent/mailpit/server/websockets" 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // GetAllTags (method: GET) will get all tags currently in use 13 | func GetAllTags(w http.ResponseWriter, _ *http.Request) { 14 | // swagger:route GET /api/v1/tags tags GetAllTags 15 | // 16 | // # Get all current tags 17 | // 18 | // Returns a JSON array of all unique message tags. 19 | // 20 | // Produces: 21 | // - application/json 22 | // 23 | // Schemes: http, https 24 | // 25 | // Responses: 26 | // 200: ArrayResponse 27 | // 400: ErrorResponse 28 | 29 | w.Header().Add("Content-Type", "application/json") 30 | if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil { 31 | httpError(w, err.Error()) 32 | } 33 | } 34 | 35 | // swagger:parameters SetTagsParams 36 | type setTagsParams struct { 37 | // in: body 38 | Body struct { 39 | // Array of tag names to set 40 | // 41 | // required: true 42 | // example: ["Tag 1", "Tag 2"] 43 | Tags []string 44 | 45 | // Array of message database IDs 46 | // 47 | // required: true 48 | // example: ["4oRBnPtCXgAqZniRhzLNmS", "hXayS6wnCgNnt6aFTvmOF6"] 49 | IDs []string 50 | } 51 | } 52 | 53 | // SetMessageTags (method: PUT) will set the tags for all provided IDs 54 | func SetMessageTags(w http.ResponseWriter, r *http.Request) { 55 | // swagger:route PUT /api/v1/tags tags SetTagsParams 56 | // 57 | // # Set message tags 58 | // 59 | // This will overwrite any existing tags for selected message database IDs. To remove all tags from a message, pass an empty tags array. 60 | // 61 | // Consumes: 62 | // - application/json 63 | // 64 | // Produces: 65 | // - text/plain 66 | // 67 | // Schemes: http, https 68 | // 69 | // Responses: 70 | // 200: OKResponse 71 | // 400: ErrorResponse 72 | 73 | decoder := json.NewDecoder(r.Body) 74 | 75 | var data struct { 76 | Tags []string 77 | IDs []string 78 | } 79 | 80 | err := decoder.Decode(&data) 81 | if err != nil { 82 | httpError(w, err.Error()) 83 | return 84 | } 85 | 86 | ids := data.IDs 87 | 88 | if len(ids) > 0 { 89 | for _, id := range ids { 90 | if _, err := storage.SetMessageTags(id, data.Tags); err != nil { 91 | httpError(w, err.Error()) 92 | return 93 | } 94 | } 95 | } 96 | 97 | w.Header().Add("Content-Type", "text/plain") 98 | _, _ = w.Write([]byte("ok")) 99 | } 100 | 101 | // swagger:parameters RenameTagParams 102 | type renameTagParams struct { 103 | // The url-encoded tag name to rename 104 | // 105 | // in: path 106 | // required: true 107 | // type: string 108 | Tag string 109 | 110 | // in: body 111 | Body struct { 112 | // New name 113 | // 114 | // required: true 115 | // example: New name 116 | Name string 117 | } 118 | } 119 | 120 | // RenameTag (method: PUT) used to rename a tag 121 | func RenameTag(w http.ResponseWriter, r *http.Request) { 122 | // swagger:route PUT /api/v1/tags/{Tag} tags RenameTagParams 123 | // 124 | // # Rename a tag 125 | // 126 | // Renames an existing tag. 127 | // 128 | // Produces: 129 | // - text/plain 130 | // 131 | // Schemes: http, https 132 | // 133 | // Responses: 134 | // 200: OKResponse 135 | // 400: ErrorResponse 136 | 137 | vars := mux.Vars(r) 138 | 139 | tag := vars["tag"] 140 | 141 | decoder := json.NewDecoder(r.Body) 142 | 143 | var data struct { 144 | Name string 145 | } 146 | 147 | err := decoder.Decode(&data) 148 | if err != nil { 149 | httpError(w, err.Error()) 150 | return 151 | } 152 | 153 | if err := storage.RenameTag(tag, data.Name); err != nil { 154 | httpError(w, err.Error()) 155 | return 156 | } 157 | 158 | websockets.Broadcast("prune", nil) 159 | 160 | w.Header().Add("Content-Type", "text/plain") 161 | _, _ = w.Write([]byte("ok")) 162 | } 163 | 164 | // swagger:parameters DeleteTagParams 165 | type deleteTagParams struct { 166 | // The url-encoded tag name to delete 167 | // 168 | // in: path 169 | // required: true 170 | Tag string 171 | } 172 | 173 | // DeleteTag (method: DELETE) used to delete a tag 174 | func DeleteTag(w http.ResponseWriter, r *http.Request) { 175 | // swagger:route DELETE /api/v1/tags/{Tag} tags DeleteTagParams 176 | // 177 | // # Delete a tag 178 | // 179 | // Deletes a tag. This will not delete any messages with the tag, but will remove the tag from any messages containing the tag. 180 | // 181 | // Produces: 182 | // - text/plain 183 | // 184 | // Schemes: http, https 185 | // 186 | // Responses: 187 | // 200: OKResponse 188 | // 400: ErrorResponse 189 | 190 | vars := mux.Vars(r) 191 | 192 | tag := vars["tag"] 193 | 194 | if err := storage.DeleteTag(tag); err != nil { 195 | httpError(w, err.Error()) 196 | return 197 | } 198 | 199 | websockets.Broadcast("prune", nil) 200 | 201 | w.Header().Add("Content-Type", "text/plain") 202 | _, _ = w.Write([]byte("ok")) 203 | } 204 | -------------------------------------------------------------------------------- /server/apiv1/thumbnails.go: -------------------------------------------------------------------------------- 1 | package apiv1 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | "image/jpeg" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/axllent/mailpit/internal/logger" 14 | "github.com/axllent/mailpit/internal/storage" 15 | "github.com/gorilla/mux" 16 | "github.com/jhillyerd/enmime/v2" 17 | "github.com/kovidgoyal/imaging" 18 | ) 19 | 20 | var ( 21 | thumbWidth = 180 22 | thumbHeight = 120 23 | ) 24 | 25 | // swagger:parameters ThumbnailParams 26 | type thumbnailParams struct { 27 | // Message database ID or "latest" 28 | // 29 | // in: path 30 | // required: true 31 | ID string 32 | 33 | // Attachment part ID 34 | // 35 | // in: path 36 | // required: true 37 | PartID string 38 | } 39 | 40 | // Thumbnail returns a thumbnail image for an attachment (images only) 41 | func Thumbnail(w http.ResponseWriter, r *http.Request) { 42 | // swagger:route GET /api/v1/message/{ID}/part/{PartID}/thumb message ThumbnailParams 43 | // 44 | // # Get an attachment image thumbnail 45 | // 46 | // This will return a cropped 180x120 JPEG thumbnail of an image attachment. 47 | // 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. 48 | // 49 | // The ID can be set to `latest` to return the latest message. 50 | // 51 | // Produces: 52 | // - image/jpeg 53 | // 54 | // Schemes: http, https 55 | // 56 | // Responses: 57 | // 200: BinaryResponse 58 | // 400: ErrorResponse 59 | 60 | vars := mux.Vars(r) 61 | 62 | id := vars["id"] 63 | partID := vars["partID"] 64 | 65 | a, err := storage.GetAttachmentPart(id, partID) 66 | if err != nil { 67 | httpError(w, err.Error()) 68 | return 69 | } 70 | 71 | fileName := a.FileName 72 | if fileName == "" { 73 | fileName = a.ContentID 74 | } 75 | 76 | if !strings.HasPrefix(a.ContentType, "image/") { 77 | blankImage(a, w) 78 | return 79 | } 80 | 81 | buf := bytes.NewBuffer(a.Content) 82 | 83 | img, err := imaging.Decode(buf, imaging.AutoOrientation(true)) 84 | if err != nil { 85 | // it's not an image, return default 86 | logger.Log().Warnf("[image] %s", err.Error()) 87 | blankImage(a, w) 88 | return 89 | } 90 | 91 | var b bytes.Buffer 92 | foo := bufio.NewWriter(&b) 93 | 94 | var dstImageFill *image.NRGBA 95 | 96 | if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight { 97 | dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos) 98 | } else { 99 | dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) 100 | } 101 | // create white image and paste image over the top 102 | // preventing black backgrounds for transparent GIF/PNG images 103 | dst := imaging.New(thumbWidth, thumbHeight, color.White) 104 | // paste the original over the top 105 | dst = imaging.OverlayCenter(dst, dstImageFill, 1.0) 106 | 107 | if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil { 108 | logger.Log().Warnf("[image] %s", err.Error()) 109 | blankImage(a, w) 110 | return 111 | } 112 | 113 | w.Header().Add("Content-Type", "image/jpeg") 114 | w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") 115 | _, _ = w.Write(b.Bytes()) 116 | } 117 | 118 | // Return a blank image instead of an error when file or image not supported 119 | func blankImage(a *enmime.Part, w http.ResponseWriter) { 120 | rect := image.Rect(0, 0, thumbWidth, thumbHeight) 121 | img := image.NewRGBA(rect) 122 | background := color.RGBA{255, 255, 255, 255} 123 | draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.Point{}, draw.Src) 124 | var b bytes.Buffer 125 | foo := bufio.NewWriter(&b) 126 | dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos) 127 | 128 | if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil { 129 | logger.Log().Warnf("[image] %s", err.Error()) 130 | } 131 | 132 | fileName := a.FileName 133 | if fileName == "" { 134 | fileName = a.ContentID 135 | } 136 | 137 | w.Header().Add("Content-Type", "image/jpeg") 138 | w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") 139 | _, _ = w.Write(b.Bytes()) 140 | } 141 | -------------------------------------------------------------------------------- /server/embed.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | "path" 7 | "strings" 8 | 9 | "github.com/axllent/mailpit/config" 10 | ) 11 | 12 | var ( 13 | //go:embed ui 14 | distFS embed.FS 15 | ) 16 | 17 | // EmbedController is a simple controller to return a file from the embedded filesystem. 18 | // 19 | // This controller is replaces Go's default http.FileServer which, as of Go v1.23, removes 20 | // the Content-Encoding header from error responses, breaking pages such as 404's while 21 | // using gzip compression middleware. 22 | func embedController(w http.ResponseWriter, r *http.Request) { 23 | p := r.URL.Path 24 | 25 | if strings.HasSuffix(p, "/") { 26 | p = p + "index.html" 27 | } 28 | 29 | p = strings.TrimPrefix(p, config.Webroot) // server webroot config 30 | p = path.Join("ui", p) // add go:embed path to path prefix 31 | 32 | b, err := distFS.ReadFile(p) 33 | if err != nil { 34 | http.Error(w, "File not found", http.StatusNotFound) 35 | return 36 | } 37 | 38 | // ensure any HTML files have the correct nonce 39 | if strings.HasSuffix(p, ".html") { 40 | nonce := r.Header.Get("mp-nonce") 41 | b = []byte(strings.ReplaceAll(string(b), "%%NONCE%%", nonce)) 42 | } 43 | 44 | // allow browser cache except for ?dev queries and HTML files 45 | if r.URL.RawQuery != "dev" && !strings.HasSuffix(p, ".html") { 46 | w.Header().Set("Cache-Control", "max-age=31536000, public, immutable") 47 | } 48 | 49 | w.Header().Set("Content-Type", contentType(p)) 50 | _, _ = w.Write(b) 51 | } 52 | 53 | // ContentType supports only a few content types, limited to this application's needs. 54 | func contentType(p string) string { 55 | switch { 56 | case strings.HasSuffix(p, ".html"): 57 | return "text/html; charset=utf-8" 58 | case strings.HasSuffix(p, ".css"): 59 | return "text/css; charset=utf-8" 60 | case strings.HasSuffix(p, ".js"): 61 | return "application/javascript; charset=utf-8" 62 | case strings.HasSuffix(p, ".json"): 63 | return "application/json" 64 | case strings.HasSuffix(p, ".svg"): 65 | return "image/svg+xml" 66 | case strings.HasSuffix(p, ".ico"): 67 | return "image/x-icon" 68 | case strings.HasSuffix(p, ".png"): 69 | return "image/png" 70 | case strings.HasSuffix(p, ".jpg"): 71 | return "image/jpeg" 72 | case strings.HasSuffix(p, ".gif"): 73 | return "image/gif" 74 | case strings.HasSuffix(p, ".woff"): 75 | return "font/woff" 76 | case strings.HasSuffix(p, ".woff2"): 77 | return "font/woff2" 78 | default: 79 | return "text/plain" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /server/handlers/k8healthz.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "net/http" 4 | 5 | // HealthzHandler is a liveness probe 6 | func HealthzHandler(w http.ResponseWriter, _ *http.Request) { 7 | w.WriteHeader(http.StatusOK) 8 | } 9 | -------------------------------------------------------------------------------- /server/handlers/k8sready.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "sync/atomic" 6 | 7 | "github.com/axllent/mailpit/internal/storage" 8 | ) 9 | 10 | // ReadyzHandler is a ready probe that signals k8s to be able to retrieve traffic 11 | func ReadyzHandler(isReady *atomic.Value) http.HandlerFunc { 12 | return func(w http.ResponseWriter, _ *http.Request) { 13 | if isReady == nil || !isReady.Load().(bool) || storage.Ping() != nil { 14 | http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) 15 | return 16 | } 17 | w.WriteHeader(http.StatusOK) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/handlers/messages.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/axllent/mailpit/config" 9 | "github.com/axllent/mailpit/internal/storage" 10 | ) 11 | 12 | // RedirectToLatestMessage (method: GET) redirects the web UI to the latest message 13 | func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) { 14 | var messages []storage.MessageSummary 15 | var err error 16 | 17 | search := strings.TrimSpace(r.URL.Query().Get("query")) 18 | if search != "" { 19 | messages, _, err = storage.Search(search, "", 0, 0, 1) 20 | if err != nil { 21 | httpError(w, err.Error()) 22 | return 23 | } 24 | } else { 25 | messages, err = storage.List(0, 0, 1) 26 | if err != nil { 27 | httpError(w, err.Error()) 28 | return 29 | } 30 | } 31 | 32 | uri := config.Webroot 33 | 34 | if len(messages) == 1 { 35 | uri, err = url.JoinPath(uri, "/view/"+messages[0].ID) 36 | if err != nil { 37 | httpError(w, err.Error()) 38 | return 39 | } 40 | } 41 | 42 | http.Redirect(w, r, uri, 302) 43 | } 44 | -------------------------------------------------------------------------------- /server/handlers/proxy.go: -------------------------------------------------------------------------------- 1 | // Package handlers contains a specific handlers 2 | package handlers 3 | 4 | import ( 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "github.com/axllent/mailpit/config" 15 | "github.com/axllent/mailpit/internal/logger" 16 | ) 17 | 18 | var linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) 19 | 20 | // ProxyHandler is used to proxy assets for printing 21 | func ProxyHandler(w http.ResponseWriter, r *http.Request) { 22 | uri := strings.TrimSpace(r.URL.Query().Get("url")) 23 | if uri == "" { 24 | logger.Log().Warn("[proxy] URL missing") 25 | httpError(w, "Error: URL missing") 26 | return 27 | } 28 | 29 | if !linkRe.MatchString(uri) { 30 | logger.Log().Warnf("[proxy] invalid URL %s", uri) 31 | httpError(w, "Error: invalid URL") 32 | return 33 | } 34 | 35 | tr := &http.Transport{} 36 | 37 | if config.AllowUntrustedTLS { 38 | tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec 39 | } 40 | 41 | client := &http.Client{ 42 | Transport: tr, 43 | Timeout: 10 * time.Second, 44 | } 45 | 46 | req, err := http.NewRequest("GET", uri, nil) 47 | if err != nil { 48 | logger.Log().Warnf("[proxy] %s", err.Error()) 49 | httpError(w, err.Error()) 50 | return 51 | } 52 | 53 | // use requesting useragent 54 | req.Header.Set("User-Agent", r.UserAgent()) 55 | 56 | resp, err := client.Do(req) 57 | if err != nil { 58 | logger.Log().Warnf("[proxy] %s", err.Error()) 59 | httpError(w, err.Error()) 60 | return 61 | } 62 | 63 | defer resp.Body.Close() 64 | body, err := io.ReadAll(resp.Body) 65 | if err != nil { 66 | logger.Log().Warnf("[proxy] %s", err.Error()) 67 | httpError(w, err.Error()) 68 | return 69 | } 70 | 71 | // relay common headers 72 | if resp.Header.Get("content-type") != "" { 73 | w.Header().Set("content-type", resp.Header.Get("content-type")) 74 | } 75 | if resp.Header.Get("last-modified") != "" { 76 | w.Header().Set("last-modified", resp.Header.Get("last-modified")) 77 | } 78 | if resp.Header.Get("content-disposition") != "" { 79 | w.Header().Set("content-disposition", resp.Header.Get("content-disposition")) 80 | } 81 | if resp.Header.Get("cache-control") != "" { 82 | w.Header().Set("cache-control", resp.Header.Get("cache-control")) 83 | } 84 | 85 | // replace url() values with proxy address, eg: fonts & images 86 | if strings.HasPrefix(resp.Header.Get("content-type"), "text/css") { 87 | var re = regexp.MustCompile(`(?mi)(url\((\'|\")?([^\)\'\"]+)(\'|\")?\))`) 88 | body = re.ReplaceAllFunc(body, func(s []byte) []byte { 89 | parts := re.FindStringSubmatch(string(s)) 90 | 91 | // don't resolve inline `data:..` 92 | if strings.HasPrefix(parts[3], "data:") { 93 | return []byte(parts[3]) 94 | } 95 | 96 | address, err := absoluteURL(parts[3], uri) 97 | if err != nil { 98 | logger.Log().Errorf("[proxy] %s", err.Error()) 99 | return []byte(parts[3]) 100 | } 101 | 102 | return []byte("url(" + parts[2] + config.Webroot + "proxy?url=" + url.QueryEscape(address) + parts[4] + ")") 103 | }) 104 | } 105 | 106 | logger.Log().Debugf("[proxy] %s (%d)", uri, resp.StatusCode) 107 | 108 | // relay status code - WriteHeader must come after Header.Set() 109 | w.WriteHeader(resp.StatusCode) 110 | 111 | if _, err := w.Write(body); err != nil { 112 | logger.Log().Warnf("[proxy] %s", err.Error()) 113 | } 114 | } 115 | 116 | // AbsoluteURL will return a full URL regardless whether it is relative or absolute 117 | func absoluteURL(link, baseURL string) (string, error) { 118 | // scheme relative links, eg 41 | 42 | 49 | -------------------------------------------------------------------------------- /server/ui-src/app.js: -------------------------------------------------------------------------------- 1 | import App from './App.vue' 2 | import router from './router' 3 | import { createApp } from 'vue' 4 | import mitt from 'mitt'; 5 | 6 | import './assets/styles.scss' 7 | import 'bootstrap-icons/font/bootstrap-icons.scss' 8 | import 'bootstrap' 9 | import 'vue-css-donut-chart/src/styles/main.css' 10 | 11 | const app = createApp(App) 12 | 13 | // Global event bus used to subscribe to websocket events 14 | // such as message deletes, updates & truncation. 15 | const eventBus = mitt() 16 | app.provide('eventBus', eventBus) 17 | 18 | app.use(router) 19 | app.mount('#app') 20 | -------------------------------------------------------------------------------- /server/ui-src/assets/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "_bootstrap_variables"; 2 | 3 | // scss-docs-start import-stack 4 | // Configuration 5 | @import "bootstrap/scss/functions"; 6 | @import "bootstrap/scss/variables"; 7 | @import "bootstrap/scss/variables-dark"; 8 | @import "bootstrap/scss/maps"; 9 | @import "bootstrap/scss/mixins"; 10 | @import "bootstrap/scss/utilities"; 11 | 12 | // Layout & components 13 | @import "bootstrap/scss/root"; 14 | @import "bootstrap/scss/reboot"; 15 | @import "bootstrap/scss/type"; 16 | @import "bootstrap/scss/images"; 17 | @import "bootstrap/scss/containers"; 18 | @import "bootstrap/scss/grid"; 19 | @import "bootstrap/scss/tables"; 20 | @import "bootstrap/scss/forms"; 21 | @import "bootstrap/scss/buttons"; 22 | @import "bootstrap/scss/transitions"; 23 | @import "bootstrap/scss/dropdown"; 24 | @import "bootstrap/scss/button-group"; 25 | @import "bootstrap/scss/nav"; 26 | @import "bootstrap/scss/navbar"; 27 | @import "bootstrap/scss/card"; 28 | @import "bootstrap/scss/accordion"; 29 | // @import "bootstrap/scss/breadcrumb"; 30 | // @import "bootstrap/scss/pagination"; 31 | @import "bootstrap/scss/badge"; 32 | @import "bootstrap/scss/alert"; 33 | // @import "bootstrap/scss/progress"; 34 | @import "bootstrap/scss/list-group"; 35 | @import "bootstrap/scss/close"; 36 | @import "bootstrap/scss/toasts"; 37 | @import "bootstrap/scss/modal"; 38 | @import "bootstrap/scss/tooltip"; 39 | // @import "bootstrap/scss/popover"; 40 | // @import "bootstrap/scss/carousel"; 41 | @import "bootstrap/scss/spinners"; 42 | @import "bootstrap/scss/offcanvas"; 43 | // @import "bootstrap/scss/popover"; 44 | @import "bootstrap/scss/progress"; 45 | 46 | // Helpers 47 | @import "bootstrap/scss/helpers"; 48 | 49 | // Utilities 50 | @import "bootstrap/scss/utilities/api"; 51 | // scss-docs-end import-stack 52 | -------------------------------------------------------------------------------- /server/ui-src/assets/_bootstrap_variables.scss: -------------------------------------------------------------------------------- 1 | // Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92 2 | $font-family-sans-serif: 3 | system-ui, 4 | -apple-system, 5 | "Segoe UI", 6 | Roboto, 7 | "Helvetica Neue", 8 | "Noto Sans", 9 | "Liberation Sans", 10 | Arial, 11 | sans-serif, 12 | "Apple Color Emoji", 13 | "Segoe UI Emoji", 14 | "Segoe UI Symbol"; 15 | 16 | $link-decoration: none; 17 | $primary: #2c3e50; 18 | $secondary: #495057; 19 | $list-group-disabled-color: #adb5bd; 20 | $enable-negative-margins: true; 21 | $body-color-dark: #e7eaed; 22 | $offcanvas-border-width: 0; 23 | $body-color: #080808; 24 | -------------------------------------------------------------------------------- /server/ui-src/components/AjaxLoader.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /server/ui-src/components/AppBadge.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /server/ui-src/components/EditTags.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 120 | -------------------------------------------------------------------------------- /server/ui-src/components/Favicon.vue: -------------------------------------------------------------------------------- 1 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /server/ui-src/components/NavSelected.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 119 | -------------------------------------------------------------------------------- /server/ui-src/components/NavTags.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 113 | -------------------------------------------------------------------------------- /server/ui-src/components/Pagination.vue: -------------------------------------------------------------------------------- 1 | 84 | 108 | -------------------------------------------------------------------------------- /server/ui-src/components/SearchForm.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 83 | -------------------------------------------------------------------------------- /server/ui-src/components/message/Headers.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 37 | -------------------------------------------------------------------------------- /server/ui-src/docs.js: -------------------------------------------------------------------------------- 1 | import "rapidoc"; 2 | -------------------------------------------------------------------------------- /server/ui-src/mixins/MessagesMixins.js: -------------------------------------------------------------------------------- 1 | import CommonMixins from './CommonMixins.js' 2 | import { mailbox } from '../stores/mailbox.js' 3 | import { pagination } from '../stores/pagination.js' 4 | 5 | export default { 6 | mixins: [CommonMixins], 7 | 8 | data() { 9 | return { 10 | apiURI: false, 11 | pagination, 12 | mailbox, 13 | } 14 | }, 15 | 16 | watch: { 17 | 'mailbox.refresh': function (v) { 18 | if (v) { 19 | // trigger a refresh 20 | this.loadMessages() 21 | } 22 | 23 | mailbox.refresh = false 24 | } 25 | }, 26 | 27 | methods: { 28 | reloadMailbox() { 29 | pagination.start = 0 30 | this.loadMessages() 31 | }, 32 | 33 | loadMessages() { 34 | if (!this.apiURI) { 35 | alert('apiURL not set!') 36 | return 37 | } 38 | 39 | // auto-pagination changes the URL but should not fetch new messages 40 | // when viewing page > 0 and new messages are received (inbox only) 41 | if (!mailbox.autoPaginating) { 42 | mailbox.autoPaginating = true // reset 43 | return 44 | } 45 | 46 | const params = {} 47 | mailbox.selected = [] 48 | 49 | params['limit'] = pagination.limit 50 | if (pagination.start > 0) { 51 | params['start'] = pagination.start 52 | } 53 | 54 | this.get(this.apiURI, params, (response) => { 55 | mailbox.total = response.data.total // all messages 56 | mailbox.unread = response.data.unread // all unread messages 57 | mailbox.tags = response.data.tags // all tags 58 | mailbox.messages = response.data.messages // current messages 59 | mailbox.count = response.data.messages_count // total results for this mailbox/search 60 | mailbox.messages_unread = response.data.messages_unread // total unread results for this mailbox/search 61 | // ensure the pagination remains consistent 62 | pagination.start = response.data.start 63 | 64 | if (response.data.count == 0 && response.data.start > 0) { 65 | pagination.start = 0 66 | return this.loadMessages() 67 | } 68 | 69 | if (mailbox.lastMessage) { 70 | window.setTimeout(() => { 71 | const m = document.getElementById(mailbox.lastMessage) 72 | if (m) { 73 | m.focus() 74 | // m.scrollIntoView({ behavior: 'smooth', block: 'center' }) 75 | m.scrollIntoView({ block: 'center' }) 76 | } else { 77 | const mp = document.getElementById('message-page') 78 | if (mp) { 79 | mp.scrollTop = 0 80 | } 81 | } 82 | 83 | mailbox.lastMessage = false 84 | }, 50) 85 | 86 | } else if (!window.scrollInPlace) { 87 | const mp = document.getElementById('message-page') 88 | if (mp) { 89 | mp.scrollTop = 0 90 | } 91 | } 92 | 93 | window.scrollInPlace = false 94 | }) 95 | }, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /server/ui-src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import MailboxView from '../views/MailboxView.vue' 3 | import MessageView from '../views/MessageView.vue' 4 | import NotFoundView from '../views/NotFoundView.vue' 5 | import SearchView from '../views/SearchView.vue' 6 | 7 | let d = document.getElementById('app') 8 | let webroot = '/' 9 | if (d) { 10 | webroot = d.dataset.webroot 11 | } 12 | 13 | // paths are relative to webroot 14 | const router = createRouter({ 15 | history: createWebHistory(webroot), 16 | routes: [ 17 | { 18 | path: '/', 19 | component: MailboxView 20 | }, 21 | { 22 | path: '/search', 23 | component: SearchView 24 | }, 25 | { 26 | path: '/view/:id', 27 | component: MessageView 28 | }, 29 | { 30 | path: '/:pathMatch(.*)*', 31 | name: 'NotFound', 32 | component: NotFoundView 33 | } 34 | ] 35 | }) 36 | 37 | export default router 38 | -------------------------------------------------------------------------------- /server/ui-src/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axllent/mailpit/020d5b0fcb80e5beb5129dcdd30e5925b49bb10d/server/ui-src/screenshot.png -------------------------------------------------------------------------------- /server/ui-src/stores/mailbox.js: -------------------------------------------------------------------------------- 1 | // State Management 2 | 3 | import { reactive, watch } from 'vue' 4 | 5 | // global mailbox info 6 | export const mailbox = reactive({ 7 | total: 0, // total number of messages in database 8 | unread: 0, // total unread messages in database 9 | count: 0, // total in mailbox or search 10 | messages: [], // current messages 11 | tags: [], // all tags 12 | selected: [], // currently selected 13 | connected: false, // websocket connection 14 | searching: false, // current search, false for none 15 | refresh: false, // to listen from MessagesMixin 16 | autoPaginating: true, // allows temporary bypass of loadMessages() via auto-pagination 17 | notificationsSupported: false, // browser supports notifications 18 | notificationsEnabled: false, // user has enabled notifications 19 | skipConfirmations: false, // skip modal confirmations for "Delete all" & "mark all read" 20 | appInfo: {}, // application information 21 | uiConfig: {}, // configuration for UI 22 | lastMessage: false, // return scrolling 23 | 24 | // settings 25 | showTagColors: !localStorage.getItem('hideTagColors') == '1', 26 | showHTMLCheck: !localStorage.getItem('hideHTMLCheck') == '1', 27 | showLinkCheck: !localStorage.getItem('hideLinkCheck') == '1', 28 | showSpamCheck: !localStorage.getItem('hideSpamCheck') == '1', 29 | timeZone: localStorage.getItem('timeZone') ? localStorage.getItem('timeZone') : Intl.DateTimeFormat().resolvedOptions().timeZone, 30 | }) 31 | 32 | watch( 33 | () => mailbox.count, 34 | (v) => { 35 | mailbox.selected = [] 36 | } 37 | ) 38 | 39 | watch( 40 | () => mailbox.showTagColors, 41 | (v) => { 42 | if (v) { 43 | localStorage.removeItem('hideTagColors') 44 | } else { 45 | localStorage.setItem('hideTagColors', '1') 46 | } 47 | } 48 | ) 49 | 50 | watch( 51 | () => mailbox.showHTMLCheck, 52 | (v) => { 53 | if (v) { 54 | localStorage.removeItem('hideHTMLCheck') 55 | } else { 56 | localStorage.setItem('hideHTMLCheck', '1') 57 | } 58 | } 59 | ) 60 | 61 | watch( 62 | () => mailbox.showLinkCheck, 63 | (v) => { 64 | if (v) { 65 | localStorage.removeItem('hideLinkCheck') 66 | } else { 67 | localStorage.setItem('hideLinkCheck', '1') 68 | } 69 | } 70 | ) 71 | 72 | watch( 73 | () => mailbox.showSpamCheck, 74 | (v) => { 75 | if (v) { 76 | localStorage.removeItem('hideSpamCheck') 77 | } else { 78 | localStorage.setItem('hideSpamCheck', '1') 79 | } 80 | } 81 | ) 82 | 83 | watch( 84 | () => mailbox.timeZone, 85 | (v) => { 86 | if (v == Intl.DateTimeFormat().resolvedOptions().timeZone) { 87 | localStorage.removeItem('timeZone') 88 | } else { 89 | localStorage.setItem('timeZone', v) 90 | } 91 | } 92 | ) 93 | -------------------------------------------------------------------------------- /server/ui-src/stores/pagination.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue' 2 | 3 | export const pagination = reactive({ 4 | start: 0, // pagination offset 5 | limit: 50, // per page 6 | defaultLimit: 50, // used to shorten URL's if current limit == defaultLimit 7 | total: 0, // total results of current view / filter 8 | count: 0, // number of messages currently displayed 9 | }) 10 | 11 | export const limitOptions = [25, 50, 100, 200] 12 | -------------------------------------------------------------------------------- /server/ui-src/views/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | -------------------------------------------------------------------------------- /server/ui/api/v1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Mailpit API v1 documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 |
Mailpit API v1 documentation
23 | 24 | Mailpit 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/ui/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axllent/mailpit/020d5b0fcb80e5beb5129dcdd30e5925b49bb10d/server/ui/favicon.ico -------------------------------------------------------------------------------- /server/ui/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /server/ui/mailpit.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /server/ui/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axllent/mailpit/020d5b0fcb80e5beb5129dcdd30e5925b49bb10d/server/ui/notification.png -------------------------------------------------------------------------------- /server/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | // Package webhook will optionally call a preconfigured endpoint 2 | package webhook 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/axllent/mailpit/config" 11 | "github.com/axllent/mailpit/internal/logger" 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | var ( 16 | // RateLimit is the minimum number of seconds between requests 17 | RateLimit = 1 18 | 19 | rl rate.Sometimes 20 | 21 | rateLimiterSet bool 22 | ) 23 | 24 | // Send will post the MessageSummary to a webhook (if configured) 25 | func Send(msg interface{}) { 26 | if config.WebhookURL == "" { 27 | return 28 | } 29 | 30 | if !rateLimiterSet { 31 | if RateLimit > 0 { 32 | rl = rate.Sometimes{Interval: time.Duration(RateLimit) * time.Second} 33 | } else { 34 | // run 1000 per second - ie: do not limit 35 | rl = rate.Sometimes{First: 1000, Interval: time.Second} 36 | } 37 | rateLimiterSet = true 38 | } 39 | 40 | go func() { 41 | rl.Do(func() { 42 | b, err := json.Marshal(msg) 43 | if err != nil { 44 | logger.Log().Errorf("[webhook] invalid data: %s", err.Error()) 45 | return 46 | } 47 | 48 | req, err := http.NewRequest("POST", config.WebhookURL, bytes.NewBuffer(b)) 49 | if err != nil { 50 | logger.Log().Errorf("[webhook] error: %s", err.Error()) 51 | return 52 | } 53 | 54 | req.Header.Set("User-Agent", "Mailpit/"+config.Version) 55 | req.Header.Set("Content-Type", "application/json") 56 | 57 | if config.Label != "" { 58 | req.Header.Set("Mailpit-Label", config.Label) 59 | } 60 | 61 | client := &http.Client{} 62 | resp, err := client.Do(req) 63 | if err != nil { 64 | logger.Log().Errorf("[webhook] error sending data: %s", err.Error()) 65 | return 66 | } 67 | 68 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 69 | logger.Log().Warnf("[webhook] %s returned a %d status", config.WebhookURL, resp.StatusCode) 70 | return 71 | } 72 | 73 | defer resp.Body.Close() 74 | }) 75 | }() 76 | } 77 | -------------------------------------------------------------------------------- /server/websockets/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package websockets 6 | 7 | import ( 8 | "net/http" 9 | "time" 10 | 11 | "github.com/axllent/mailpit/internal/auth" 12 | "github.com/axllent/mailpit/internal/logger" 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | const ( 17 | // Time allowed to write a message to the peer. 18 | writeWait = 10 * time.Second 19 | 20 | // Time allowed to read the next pong message from the peer. 21 | pongWait = 60 * time.Second 22 | 23 | // Send pings to peer with this period. Must be less than pongWait. 24 | pingPeriod = (pongWait * 9) / 10 25 | ) 26 | 27 | var ( 28 | newline = []byte{'\n'} 29 | 30 | // MessageHub global 31 | MessageHub *Hub 32 | ) 33 | 34 | var upgrader = websocket.Upgrader{ 35 | ReadBufferSize: 1024, 36 | WriteBufferSize: 1024, 37 | CheckOrigin: func(r *http.Request) bool { return true }, // allow multi-domain 38 | EnableCompression: true, // experimental compression 39 | } 40 | 41 | // Client is a middleman between the websocket connection and the hub. 42 | type Client struct { 43 | hub *Hub 44 | 45 | // The websocket connection. 46 | conn *websocket.Conn 47 | 48 | // Buffered channel of outbound messages. 49 | send chan []byte 50 | } 51 | 52 | // ReadPump is used here solely to monitor the connection, not to actually receive messages. 53 | func (c *Client) readPump() { 54 | defer func() { 55 | c.hub.unregister <- c 56 | }() 57 | 58 | for { 59 | _, _, err := c.conn.NextReader() 60 | if err != nil { 61 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 62 | logger.Log().Errorf("[websocket] error: %v", err.Error()) 63 | } 64 | break 65 | } 66 | } 67 | } 68 | 69 | // WritePump pumps messages from the hub to the websocket connection. 70 | // 71 | // A goroutine running writePump is started for each connection. The 72 | // application ensures that there is at most one writer to a connection by 73 | // executing all writes from this goroutine. 74 | func (c *Client) writePump() { 75 | ticker := time.NewTicker(pingPeriod) 76 | defer func() { 77 | ticker.Stop() 78 | c.hub.unregister <- c 79 | }() 80 | for { 81 | select { 82 | case message, ok := <-c.send: 83 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 84 | if !ok { 85 | // The hub closed the channel. 86 | _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 87 | return 88 | } 89 | 90 | w, err := c.conn.NextWriter(websocket.TextMessage) 91 | if err != nil { 92 | return 93 | } 94 | _, _ = w.Write(message) 95 | 96 | // Add queued chat messages to the current websocket message. 97 | n := len(c.send) 98 | for i := 0; i < n; i++ { 99 | _, _ = w.Write(newline) 100 | _, _ = w.Write(<-c.send) 101 | } 102 | 103 | if err := w.Close(); err != nil { 104 | return 105 | } 106 | case <-ticker.C: 107 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 108 | _ = c.conn.WriteMessage(websocket.PingMessage, []byte{}) 109 | } 110 | } 111 | } 112 | 113 | // ServeWs handles websocket requests from the peer. 114 | func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { 115 | if auth.UICredentials != nil { 116 | user, pass, ok := r.BasicAuth() 117 | 118 | if !ok { 119 | basicAuthResponse(w) 120 | return 121 | } 122 | 123 | if !auth.UICredentials.Match(user, pass) { 124 | basicAuthResponse(w) 125 | return 126 | } 127 | } 128 | 129 | conn, err := upgrader.Upgrade(w, r, nil) 130 | if err != nil { 131 | logger.Log().Errorf("[websocket] %s", err.Error()) 132 | return 133 | } 134 | 135 | client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} 136 | client.hub.register <- client 137 | 138 | // Allow collection of memory referenced by the caller by doing all work in new goroutines. 139 | go client.readPump() 140 | go client.writePump() 141 | } 142 | 143 | // BasicAuthResponse returns an basic auth response to the browser 144 | func basicAuthResponse(w http.ResponseWriter) { 145 | w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) 146 | w.WriteHeader(http.StatusUnauthorized) 147 | _, _ = w.Write([]byte("Unauthorised.\n")) 148 | } 149 | -------------------------------------------------------------------------------- /server/websockets/hub.go: -------------------------------------------------------------------------------- 1 | // Package websockets is used to broadcast messages to connected clients 2 | package websockets 3 | 4 | import ( 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/axllent/mailpit/internal/logger" 9 | ) 10 | 11 | // Hub maintains the set of active clients and broadcasts messages to the 12 | // clients. 13 | type Hub struct { 14 | // Registered clients. 15 | Clients map[*Client]bool 16 | 17 | // Inbound messages from the clients. 18 | Broadcast chan []byte 19 | 20 | // Register requests from the clients. 21 | register chan *Client 22 | 23 | // Unregister requests from clients. 24 | unregister chan *Client 25 | } 26 | 27 | // WebsocketNotification struct for responses 28 | type WebsocketNotification struct { 29 | Type string 30 | Data interface{} 31 | } 32 | 33 | // NewHub returns a new hub configuration 34 | func NewHub() *Hub { 35 | return &Hub{ 36 | Broadcast: make(chan []byte), 37 | register: make(chan *Client), 38 | unregister: make(chan *Client), 39 | Clients: make(map[*Client]bool), 40 | } 41 | } 42 | 43 | // Run runs the listener 44 | func (h *Hub) Run() { 45 | for { 46 | select { 47 | case client := <-h.register: 48 | if _, ok := h.Clients[client]; !ok { 49 | logger.Log().Debugf("[websocket] client %s connected", client.conn.RemoteAddr().String()) 50 | h.Clients[client] = true 51 | } 52 | case client := <-h.unregister: 53 | if _, ok := h.Clients[client]; ok { 54 | logger.Log().Debugf("[websocket] client %s disconnected", client.conn.RemoteAddr().String()) 55 | delete(h.Clients, client) 56 | close(client.send) 57 | } 58 | case message := <-h.Broadcast: 59 | for client := range h.Clients { 60 | select { 61 | case client.send <- message: 62 | default: 63 | close(client.send) 64 | delete(h.Clients, client) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | // Broadcast will spawn a broadcast message to all connected clients 72 | func Broadcast(t string, msg interface{}) { 73 | if MessageHub == nil || len(MessageHub.Clients) == 0 { 74 | return 75 | } 76 | 77 | w := WebsocketNotification{} 78 | w.Type = t 79 | w.Data = msg 80 | b, err := json.Marshal(w) 81 | 82 | if err != nil { 83 | logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err.Error()) 84 | return 85 | } 86 | 87 | // add a very small delay to prevent broadcasts from being interpreted 88 | // as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series) 89 | time.Sleep(time.Millisecond) 90 | 91 | go func() { MessageHub.Broadcast <- b }() 92 | } 93 | 94 | // BroadCastClientError is a wrapper to broadcast client errors to the web UI 95 | func BroadCastClientError(severity, errorType, ip, message string) { 96 | msg := struct { 97 | Level string 98 | Type string 99 | IP string 100 | Message string 101 | }{ 102 | severity, 103 | errorType, 104 | ip, 105 | message, 106 | } 107 | 108 | Broadcast("error", msg) 109 | } 110 | --------------------------------------------------------------------------------