├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codecov.yaml │ ├── golangci-lint.yml │ ├── reuse.yaml │ └── sonarqube.yaml ├── .gitignore ├── .golangci.toml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── LICENSES ├── CC0-1.0.txt └── MIT.txt ├── README.md ├── REUSE.toml ├── SECURITY.md ├── codecov.yml ├── dkim ├── README.md ├── config.go ├── config_test.go ├── dkim.go └── dkim_test.go ├── go.mod ├── go.sum ├── go.sum.license ├── log ├── log.go └── log_test.go ├── openpgp ├── README.md ├── config.go ├── config_test.go ├── helper.go ├── helper_test.go ├── openpgp.go └── openpgp_test.go ├── sonar-project.properties └── subject_capitalize ├── README.md ├── subject_capitalize.go └── subject_capitalize_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | github: wneessen 6 | ko_fi: winni 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: "gomod" # See documentation for possible values 8 | directory: "/" # Location of package manifests 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | name: Codecov workflow 6 | on: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - '**.go' 12 | - 'go.*' 13 | - '.github/**' 14 | - 'codecov.yml' 15 | pull_request: 16 | branches: 17 | - main 18 | paths: 19 | - '**.go' 20 | - 'go.*' 21 | - '.github/**' 22 | - 'codecov.yml' 23 | env: 24 | PRIV_KEY_PASS: ${{ secrets.PRIV_KEY_PASS }} 25 | jobs: 26 | run: 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | os: [ubuntu-latest, macos-latest, windows-latest] 31 | go: ['1.20', '1.21', '1.22', '1.23'] 32 | steps: 33 | - name: Checkout Code 34 | uses: actions/checkout@master 35 | - name: Setup go 36 | uses: actions/setup-go@v3 37 | with: 38 | go-version: ${{ matrix.go }} 39 | - name: Run Tests 40 | run: | 41 | go test -v -race --coverprofile=coverage.coverprofile --covermode=atomic ./... 42 | - name: Upload coverage to Codecov 43 | if: success() && matrix.go == '1.23' && matrix.os == 'ubuntu-latest' 44 | uses: codecov/codecov-action@v2 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | name: golangci-lint 6 | on: 7 | push: 8 | tags: 9 | - v* 10 | branches: 11 | - main 12 | pull_request: 13 | permissions: 14 | contents: read 15 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 16 | # pull-requests: read 17 | jobs: 18 | golangci: 19 | name: lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/setup-go@v3 23 | with: 24 | go-version: 1.23 25 | - uses: actions/checkout@v3 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v3 28 | with: 29 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 30 | version: latest 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # args: --issues-exit-code=0 37 | 38 | # Optional: show only new issues if it's a pull request. The default value is `false`. 39 | # only-new-issues: true 40 | 41 | # Optional: if set to true then the all caching functionality will be complete disabled, 42 | # takes precedence over all other caching options. 43 | # skip-cache: true 44 | 45 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 46 | # skip-pkg-cache: true 47 | 48 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 49 | # skip-build-cache: true 50 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | name: REUSE Compliance Check 6 | 7 | on: [push, pull_request] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: REUSE Compliance Check 15 | uses: fsfe/reuse-action@v1 16 | -------------------------------------------------------------------------------- /.github/workflows/sonarqube.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | name: SonarQube 6 | on: 7 | push: 8 | branches: 9 | - main # or the name of your main branch 10 | pull_request: 11 | branches: 12 | - main # or the name of your main branch 13 | env: 14 | PRIV_KEY_PASS: ${{ secrets.PRIV_KEY_PASS }} 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup Go 25 | uses: actions/setup-go@v2.1.3 26 | with: 27 | go-version: '1.23' 28 | 29 | - name: Run unit Tests 30 | run: | 31 | go mod download && go test -v -race --coverprofile=./cov.out ./... 32 | 33 | - name: Run Gosec Security Scanner 34 | uses: securego/gosec@master 35 | with: 36 | args: '-no-fail -fmt sonarqube -out report.json ./...' 37 | 38 | - uses: sonarsource/sonarqube-scan-action@master 39 | env: 40 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 41 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} 42 | 43 | - uses: sonarsource/sonarqube-quality-gate-action@master 44 | timeout-minutes: 5 45 | env: 46 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Local testfiles and auth data 22 | .auth 23 | cmd/* 24 | 25 | # SonarQube 26 | .scannerwork/ 27 | 28 | # IDEA specific ignores 29 | # Source: https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 30 | 31 | # User-specific stuff 32 | .idea/**/workspace.xml 33 | .idea/**/tasks.xml 34 | .idea/**/usage.statistics.xml 35 | .idea/**/dictionaries 36 | .idea/**/shelf 37 | 38 | # AWS User-specific 39 | .idea/**/aws.xml 40 | 41 | # Generated files 42 | .idea/**/contentModel.xml 43 | 44 | # Sensitive or high-churn files 45 | .idea/**/dataSources/ 46 | .idea/**/dataSources.ids 47 | .idea/**/dataSources.local.xml 48 | .idea/**/sqlDataSources.xml 49 | .idea/**/dynamic.xml 50 | .idea/**/uiDesigner.xml 51 | .idea/**/dbnavigator.xml 52 | 53 | # Gradle 54 | .idea/**/gradle.xml 55 | .idea/**/libraries 56 | 57 | # CMake 58 | cmake-build-*/ 59 | 60 | # Mongo Explorer plugin 61 | .idea/**/mongoSettings.xml 62 | 63 | # File-based project format 64 | *.iws 65 | 66 | # IntelliJ 67 | out/ 68 | 69 | # mpeltonen/sbt-idea plugin 70 | .idea_modules/ 71 | 72 | # JIRA plugin 73 | atlassian-ide-plugin.xml 74 | 75 | # Cursive Clojure plugin 76 | .idea/replstate.xml 77 | 78 | # SonarLint plugin 79 | .idea/sonarlint/ 80 | 81 | # Crashlytics plugin (for Android Studio and IntelliJ) 82 | com_crashlytics_export_strings.xml 83 | crashlytics.properties 84 | crashlytics-build.properties 85 | fabric.properties 86 | 87 | # IdeaJ 88 | .idea/ 89 | 90 | # Examples 91 | examples/ 92 | 93 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | ## SPDX-FileCopyrightText: 2022 Winni Neessen 2 | ## 3 | ## SPDX-License-Identifier: MIT 4 | 5 | [run] 6 | go = "1.16" 7 | tests = true 8 | 9 | [linters] 10 | enable = ["stylecheck", "whitespace", "containedctx", "contextcheck", "decorder", 11 | "errname", "errorlint", "gofmt", "gofumpt"] -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Contributor Covenant Code of Conduct 8 | 9 | ## Our Pledge 10 | 11 | We as members, contributors, and leaders pledge to make participation in our 12 | community a harassment-free experience for everyone, regardless of age, body 13 | size, visible or invisible disability, ethnicity, sex characteristics, gender 14 | identity and expression, level of experience, education, socio-economic status, 15 | nationality, personal appearance, race, religion, or sexual identity 16 | and orientation. 17 | 18 | We pledge to act and interact in ways that contribute to an open, welcoming, 19 | diverse, inclusive, and healthy community. 20 | 21 | ## Our Standards 22 | 23 | Examples of behavior that contributes to a positive environment for our 24 | community include: 25 | 26 | * Demonstrating empathy and kindness toward other people 27 | * Being respectful of differing opinions, viewpoints, and experiences 28 | * Giving and gracefully accepting constructive feedback 29 | * Accepting responsibility and apologizing to those affected by our mistakes, 30 | and learning from the experience 31 | * Focusing on what is best not just for us as individuals, but for the 32 | overall community 33 | 34 | Examples of unacceptable behavior include: 35 | 36 | * The use of sexualized language or imagery, and sexual attention or 37 | advances of any kind 38 | * Trolling, insulting or derogatory comments, and personal or political attacks 39 | * Public or private harassment 40 | * Publishing others' private information, such as a physical or email 41 | address, without their explicit permission 42 | * Other conduct which could reasonably be considered inappropriate in a 43 | professional setting 44 | 45 | ## Enforcement Responsibilities 46 | 47 | Community leaders are responsible for clarifying and enforcing our standards of 48 | acceptable behavior and will take appropriate and fair corrective action in 49 | response to any behavior that they deem inappropriate, threatening, offensive, 50 | or harmful. 51 | 52 | Community leaders have the right and responsibility to remove, edit, or reject 53 | comments, commits, code, wiki edits, issues, and other contributions that are 54 | not aligned to this Code of Conduct, and will communicate reasons for moderation 55 | decisions when appropriate. 56 | 57 | ## Scope 58 | 59 | This Code of Conduct applies within all community spaces, and also applies when 60 | an individual is officially representing the community in public spaces. 61 | Examples of representing our community include using an official e-mail address, 62 | posting via an official social media account, or acting as an appointed 63 | representative at an online or offline event. 64 | 65 | ## Enforcement 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the community leaders responsible for enforcement at 69 | github+coc@neessen.net. 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | ## Enforcement Guidelines 76 | 77 | Community leaders will follow these Community Impact Guidelines in determining 78 | the consequences for any action they deem in violation of this Code of Conduct: 79 | 80 | ### 1. Correction 81 | 82 | **Community Impact**: Use of inappropriate language or other behavior deemed 83 | unprofessional or unwelcome in the community. 84 | 85 | **Consequence**: A private, written warning from community leaders, providing 86 | clarity around the nature of the violation and an explanation of why the 87 | behavior was inappropriate. A public apology may be requested. 88 | 89 | ### 2. Warning 90 | 91 | **Community Impact**: A violation through a single incident or series 92 | of actions. 93 | 94 | **Consequence**: A warning with consequences for continued behavior. No 95 | interaction with the people involved, including unsolicited interaction with 96 | those enforcing the Code of Conduct, for a specified period of time. This 97 | includes avoiding interactions in community spaces as well as external channels 98 | like social media. Violating these terms may lead to a temporary or 99 | permanent ban. 100 | 101 | ### 3. Temporary Ban 102 | 103 | **Community Impact**: A serious violation of community standards, including 104 | sustained inappropriate behavior. 105 | 106 | **Consequence**: A temporary ban from any sort of interaction or public 107 | communication with the community for a specified period of time. No public or 108 | private interaction with the people involved, including unsolicited interaction 109 | with those enforcing the Code of Conduct, is allowed during this period. 110 | Violating these terms may lead to a permanent ban. 111 | 112 | ### 4. Permanent Ban 113 | 114 | **Community Impact**: Demonstrating a pattern of violation of community 115 | standards, including sustained inappropriate behavior, harassment of an 116 | individual, or aggression toward or disparagement of classes of individuals. 117 | 118 | **Consequence**: A permanent ban from any sort of public interaction within 119 | the community. 120 | 121 | ## Attribution 122 | 123 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 124 | version 2.0, available at 125 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 126 | 127 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 128 | enforcement ladder](https://github.com/mozilla/diversity). 129 | 130 | [homepage]: https://www.contributor-covenant.org 131 | 132 | For answers to common questions about this code of conduct, see the FAQ at 133 | https://www.contributor-covenant.org/faq. Translations are available at 134 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Winni Neessen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # A collection of message middlewares for go-mail 8 | [![GoDoc](https://godoc.org/github.com/wneessen/go-mail-middleware?status.svg)](https://pkg.go.dev/github.com/wneessen/go-mail-middleware) 9 | [![codecov](https://codecov.io/gh/wneessen/go-mail-middleware/branch/main/graph/badge.svg?token=1XC87Z6QX4)](https://codecov.io/gh/wneessen/go-mail-middleware) 10 | [![Go Report Card](https://goreportcard.com/badge/github.com/wneessen/go-mail-middleware)](https://goreportcard.com/report/github.com/wneessen/go-mail-middleware) 11 | [![#go-mail on Discord](https://img.shields.io/badge/Discord-%23gomail-blue.svg)](https://discord.gg/dbfQyC4s) 12 | [![REUSE status](https://api.reuse.software/badge/github.com/wneessen/go-mail-middleware)](https://api.reuse.software/info/github.com/wneessen/go-mail-middleware) 13 | buy ma a coffee 14 | 15 | ### What is this? 16 | 17 | This repository is a collection of different useful middlewares for [go-mail](https://github.com/wneessen/go-mail). 18 | Since we want to keep `go-mail` free of third party dependencies and only depend on the Go Standard Library, we 19 | introduce a Middleware concept in version v0.2.8. This allows the user to alter a `mail.Msg` according to their 20 | needs by simple implementing tool that satisfies the `mail.Middleware` interface and provide it to the `mail.Msg` 21 | with the `mail.WithMiddleware()` option. This allows the use of 3rd party libraries with `go-mail` mail messages, 22 | while keeping `go-mail` itself dependancy free. 23 | 24 | ### List of currently supported middlewares 25 | 26 | * [dkim](dkim): DKIM (DomainKeys Identified Mail) middleware to sign mail messages 27 | * [openpgp](openpgp): OpenPGP middleware to digitally encrypt and sign mail messages (Experimental/Development on hold) 28 | * [subject_capitalize](subject_capitalize): Capitalizes the subject of the message matching the given language 29 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright (c) 2024 The go-mail Authors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | version = 1 6 | SPDX-PackageName = "go-mail-middleware" 7 | SPDX-PackageSupplier = "Winni Neessen " 8 | SPDX-PackageDownloadLocation = "https://github.com/wneessen/go-mail-middleware" 9 | 10 | [[annotations]] 11 | path = "go.sum" 12 | precedence = "aggregate" 13 | SPDX-FileCopyrightText = "2022 Winni Neessen " 14 | SPDX-License-Identifier = "MIT" 15 | 16 | [[annotations]] 17 | path = ".idea/**" 18 | precedence = "aggregate" 19 | SPDX-FileCopyrightText = "2022 Winni Neessen " 20 | SPDX-License-Identifier = "CC0-1.0" 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Security Policy 8 | 9 | ## Reporting a Vulnerability 10 | 11 | To report (possible) security issues in go-mail, please either send a mail to 12 | [security@go-mail.dev](mailto:security@go-mail.dev) or use Github's 13 | [private reporting feature](https://github.com/wneessen/go-mail-middleware/security/advisories/new). 14 | Reports are always welcome. Even if you are not 100% certain that a specific issue you found 15 | counts as a security issue, we'd love to hear the details, so we can figure out together if 16 | the issue in question needds to be addressed. 17 | 18 | Typically, you will receive an answer within a day or even within a few hours. 19 | 20 | ## Encryption 21 | You can send OpenPGP/GPG encrpyted mails to the [security@go-mail.dev](mailto:security@go-mail.dev) address. 22 | 23 | OpenPGP/GPG public key: 24 | ``` 25 | -----BEGIN PGP PUBLIC KEY BLOCK----- 26 | xjMEY8RwPBYJKwYBBAHaRw8BAQdAiLsW7pv+CCMq5Ol0hbIB1HnJI97u3zJw 27 | Wslr7GJzgOzNK3NlY3VyaXR5QGdvLW1haWwuZGV2IDxzZWN1cml0eUBnby1t 28 | YWlsLmRldj7CjAQQFgoAPgUCY8RwPAQLCQcICRCgTBOxf8keAAMVCAoEFgAC 29 | AQIZAQIbAwIeARYhBAoWEB7Y0bE7zcIOuaBME7F/yR4AAAByugD9HabWXsyD 30 | aPIDrIS97OBA1OLltB4NPT5ba9whKRxTEmMBALBiB2ML4ZTrjLqI6UbGkhJq 31 | mWeMtvmU0chZT7WNBO0PzjgEY8RwPBIKKwYBBAGXVQEFAQEHQGDEccz6gvl5 32 | t8cMMb/Dy2l0elRZL+Nd0gOhnbWMWlArAwEIB8J4BBgWCAAqBQJjxHA8CRCg 33 | TBOxf8keAAIbDBYhBAoWEB7Y0bE7zcIOuaBME7F/yR4AAADaMwD9EvEA3NSN 34 | NtdSaeL/euh6oRRiCjKzh5bIqZiQXqMlIOoBAJvPE2facs8MISwTtDoHW0sD 35 | WdOs3yBpGlGCs5WEqvQH 36 | =zn96 37 | -----END PGP PUBLIC KEY BLOCK----- 38 | ``` 39 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | coverage: 6 | status: 7 | project: 8 | default: 9 | target: 85% 10 | threshold: 5% 11 | base: auto 12 | if_ci_failed: error 13 | only_pulls: false 14 | patch: 15 | default: 16 | target: 80% 17 | base: auto 18 | if_ci_failed: error 19 | threshold: 5% 20 | 21 | comment: 22 | require_changes: true 23 | -------------------------------------------------------------------------------- /dkim/README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## DKIM (DomainKeys Identified Mail) middleware 8 | 9 | This middleware allows the DKIM signing of mails with go-mail using the 10 | [github.com/emersion/go-msgauth](https://github.com/emersion/go-msgauth) library as basis. 11 | In case you are using other middlewares, this should be the last to be applies, since 12 | alteration of the message after signing it, the verification will fail. 13 | 14 | ### Example 15 | 16 | ```go 17 | package main 18 | 19 | import ( 20 | "log" 21 | 22 | "github.com/wneessen/go-mail" 23 | "github.com/wneessen/go-mail-middleware/dkim" 24 | ) 25 | 26 | const rsaKey = `-----BEGIN RSA PRIVATE KEY----- 27 | MIICX[...] 28 | -----END RSA PRIVATE KEY-----` 29 | 30 | func main() { 31 | // First we need a config for our DKIM signer middleware 32 | sc, err := dkim.NewConfig("example.com", "mail", 33 | dkim.WithHeaderFields(mail.HeaderDate.String(), 34 | mail.HeaderFrom.String(), mail.HeaderTo.String(), 35 | mail.HeaderSubject.String()), 36 | ) 37 | if err != nil { 38 | log.Fatalf("failed to create new config: %s", err) 39 | } 40 | 41 | // We then create a new middleware based of our RSA key and the config 42 | // we just created 43 | mw, err := dkim.NewFromRSAKey([]byte(rsaKey), sc) 44 | if err != nil { 45 | log.Fatalf("failed to create new middleware from RSA key: %s", err) 46 | } 47 | 48 | // Finally we create a new mail.Msg with our middleware assigned 49 | m := mail.NewMsg(mail.WithMiddleware(mw)) 50 | if err := m.From("toni.sender@example.com"); err != nil { 51 | log.Fatalf("failed to set From address: %s", err) 52 | } 53 | if err := m.To("tina.recipient@example.com"); err != nil { 54 | log.Fatalf("failed to set To address: %s", err) 55 | } 56 | m.Subject("This is my first mail with go-mail!") 57 | m.SetBodyString(mail.TypeTextPlain, "Do you like this mail? I certainly do!") 58 | c, err := mail.NewClient("smtp.example.com", mail.WithPort(25), 59 | mail.WithSMTPAuth(mail.SMTPAuthPlain), 60 | mail.WithUsername("my_username"), mail.WithPassword("extremely_secret_pass")) 61 | if err != nil { 62 | log.Fatalf("failed to create mail client: %s", err) 63 | } 64 | if err := c.DialAndSend(m); err != nil { 65 | log.Fatalf("failed to send mail: %s", err) 66 | } 67 | } 68 | ``` -------------------------------------------------------------------------------- /dkim/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package dkim 6 | 7 | import ( 8 | "crypto" 9 | "fmt" 10 | "strings" 11 | "time" 12 | 13 | "github.com/emersion/go-msgauth/dkim" 14 | ) 15 | 16 | type SignerConfig struct { 17 | // AUID represents the DKIM Agent or User Identifier (AUID) 18 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-2.6 19 | // 20 | // A single identifier that refers to the agent or user on behalf of 21 | // whom the Signing Domain Identifier (SDID) has taken responsibility. 22 | // The AUID comprises a domain name and an optional . The 23 | // domain name is the same as that used for the SDID or is a subdomain 24 | // of it. For DKIM processing, the domain name portion of the AUID has 25 | // only basic domain name semantics; any possible owner-specific 26 | // semantics are outside the scope of DKIM. 27 | // 28 | // AUID is optional and can be empty 29 | AUID string 30 | 31 | // CanonicalizationHeader defines the type of Canonicalization used for the mail.Msg header 32 | // Some mail systems modify email in transit, potentially invalidating a 33 | // signature. For most Signers, mild modification of email is 34 | // immaterial to validation of the DKIM domain name's use. For such 35 | // Signers, a canonicalization algorithm that survives modest in-transit 36 | // modification is preferred. 37 | // 38 | // If no canonicalization is defines, we default to CanonicalizationSimple 39 | // 40 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-3.4 41 | // See also: canonicalization.go#L7 42 | CanonicalizationHeader dkim.Canonicalization 43 | 44 | // CanonicalizationBody defines the type of Canonicalization used for the mail.Msg body 45 | // Some mail systems modify email in transit, potentially invalidating a 46 | // signature. For most Signers, mild modification of email is 47 | // immaterial to validation of the DKIM domain name's use. For such 48 | // Signers, a canonicalization algorithm that survives modest in-transit 49 | // modification is preferred. 50 | // 51 | // If no canonicalization is defines, we default to CanonicalizationSimple 52 | // 53 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-3.4 54 | // See also: canonicalization.go#L7 55 | CanonicalizationBody dkim.Canonicalization 56 | 57 | // Domain represents the DKIM Signing Domain Identifier (SDID) 58 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-2.5 59 | // 60 | // A single domain name that is the mandatory payload output of DKIM 61 | // and that refers to the identity claiming some responsibility for 62 | // the message by signing it. 63 | // 64 | // Domain MUST not be empty 65 | Domain string 66 | 67 | // Expiration is an optional expiration time of the signature. 68 | // See: https://www.rfc-editor.org/rfc/rfc6376.html#section-3.5 69 | // 70 | // Signatures MAY be considered invalid if the verification time at 71 | // the Verifier is past the expiration date. The verification 72 | // time should be the time that the message was first received at 73 | // the administrative domain of the Verifier if that time is 74 | // reliably available; otherwise, the current time should be 75 | // used. The value of the "x=" tag MUST be greater than the value 76 | // of the "t=" tag if both are present. 77 | Expiration time.Time 78 | 79 | // HashAlgo represents the DKIM Hash Algorithms 80 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-7.7 81 | // 82 | // DKIM supports the following hashing algorithms 83 | // - SHA256: This is the default and prefered algorithm 84 | // - SHA1: Due to comptibility reasons SHA1 is still supported but is 85 | // not recommended to use it, since the SHA1 hashing algorithm has 86 | // been proven to be broken 87 | HashAlgo crypto.Hash 88 | 89 | // HeaderFields is an optional list of header fields that should be used in 90 | // the signature. If the list is empty, all header fields will be used. 91 | // 92 | // If a list of headers is given via the HeaderFields slice, the FROM header 93 | // is always required. 94 | // 95 | // For a list of recommended signature headers, please refer to: 96 | // https://www.rfc-editor.org/rfc/rfc6376.html#section-5.4.1 97 | HeaderFields []string 98 | 99 | // Selector represents the DKIM domain selectors 100 | // See: https://datatracker.ietf.org/doc/html/rfc6376#section-3.1 101 | // 102 | // To support multiple concurrent public keys per signing domain, the 103 | // key namespace is subdivided using "selectors". For example, 104 | // selectors might indicate the names of office locations (e.g., 105 | // "sanfrancisco", "coolumbeach", and "reykjavik"), the signing date 106 | // (e.g., "january2005", "february2005", etc.), or even an individual 107 | // user. 108 | // 109 | // Selector MUST not be empty 110 | Selector string 111 | } 112 | 113 | // SignerOption returns a function that can be used for grouping SignerConfig options 114 | type SignerOption func(config *SignerConfig) error 115 | 116 | // NewConfig returns a new SignerConfig struct. It requires a domain name d and a 117 | // domain selector s. All other values can be prefilled using the With*() SignerOption 118 | // methods 119 | func NewConfig(d string, s string, o ...SignerOption) (*SignerConfig, error) { 120 | sc := &SignerConfig{ 121 | CanonicalizationBody: dkim.CanonicalizationSimple, 122 | CanonicalizationHeader: dkim.CanonicalizationSimple, 123 | Domain: d, 124 | HashAlgo: crypto.SHA256, 125 | Selector: s, 126 | } 127 | 128 | // Override defaults with optionally provided Option functions 129 | for _, co := range o { 130 | if co == nil { 131 | continue 132 | } 133 | if err := co(sc); err != nil { 134 | return sc, fmt.Errorf("failed to apply option: %w", err) 135 | } 136 | } 137 | 138 | return sc, nil 139 | } 140 | 141 | // WithAUID provides the optional AUID value for the SignerConfig 142 | func WithAUID(a string) SignerOption { 143 | return func(sc *SignerConfig) error { 144 | sc.AUID = a 145 | return nil 146 | } 147 | } 148 | 149 | // WithHeaderCanonicalization provides the Canonicalization for the message header in the SignerConfig 150 | func WithHeaderCanonicalization(c dkim.Canonicalization) SignerOption { 151 | return func(sc *SignerConfig) error { 152 | if !sc.CanonicalizationIsValid(c) { 153 | return fmt.Errorf("%s: %w", c, ErrInvalidCanonicalization) 154 | } 155 | sc.CanonicalizationHeader = c 156 | return nil 157 | } 158 | } 159 | 160 | // WithBodyCanonicalization provides the Canonicalization for the message body in the SignerConfig 161 | func WithBodyCanonicalization(c dkim.Canonicalization) SignerOption { 162 | return func(sc *SignerConfig) error { 163 | if !sc.CanonicalizationIsValid(c) { 164 | return fmt.Errorf("%s: %w", c, ErrInvalidCanonicalization) 165 | } 166 | sc.CanonicalizationBody = c 167 | return nil 168 | } 169 | } 170 | 171 | // WithExpiration provides the optional expiration time value for the SignerConfig 172 | func WithExpiration(x time.Time) SignerOption { 173 | return func(sc *SignerConfig) error { 174 | if x.UnixNano() <= time.Now().UnixNano() { 175 | return ErrInvalidExpiration 176 | } 177 | sc.Expiration = x 178 | return nil 179 | } 180 | } 181 | 182 | // WithHashAlgo provides the Hashing algorithm to the SignerConfig 183 | func WithHashAlgo(ha crypto.Hash) SignerOption { 184 | return func(sc *SignerConfig) error { 185 | if !sc.HashAlgoIsValid(ha) { 186 | return fmt.Errorf("%s: %w", ha.String(), ErrInvalidHashAlgo) 187 | } 188 | sc.HashAlgo = ha 189 | return nil 190 | } 191 | } 192 | 193 | // WithHeaderFields provides a list of header field names that should be included 194 | // in the DKIM signature 195 | func WithHeaderFields(fl ...string) SignerOption { 196 | return func(sc *SignerConfig) error { 197 | hf := false 198 | for _, f := range fl { 199 | sc.HeaderFields = append(sc.HeaderFields, f) 200 | if strings.EqualFold(f, "From") { 201 | hf = true 202 | } 203 | } 204 | if !hf { 205 | return ErrFromRequired 206 | } 207 | return nil 208 | } 209 | } 210 | 211 | // SetAUID sets/overrides the AUID of the SignerConfig 212 | func (sc *SignerConfig) SetAUID(a string) { 213 | sc.AUID = a 214 | } 215 | 216 | // SetHeaderCanonicalization sets/overrides the Canonicalization of the SignerConfig 217 | func (sc *SignerConfig) SetHeaderCanonicalization(c dkim.Canonicalization) error { 218 | if !sc.CanonicalizationIsValid(c) { 219 | return fmt.Errorf("%s: %w", c, ErrInvalidCanonicalization) 220 | } 221 | sc.CanonicalizationHeader = c 222 | return nil 223 | } 224 | 225 | // SetBodyCanonicalization sets/overrides the Canonicalization of the SignerConfig 226 | func (sc *SignerConfig) SetBodyCanonicalization(c dkim.Canonicalization) error { 227 | if !sc.CanonicalizationIsValid(c) { 228 | return fmt.Errorf("%s: %w", c, ErrInvalidCanonicalization) 229 | } 230 | sc.CanonicalizationBody = c 231 | return nil 232 | } 233 | 234 | // SetExpiration sets/overrides the Expiration of the SignerConfig 235 | func (sc *SignerConfig) SetExpiration(x time.Time) error { 236 | if x.UnixNano() <= time.Now().UnixNano() { 237 | return ErrInvalidExpiration 238 | } 239 | sc.Expiration = x 240 | return nil 241 | } 242 | 243 | // SetHashAlgo sets/override the hashing algorithm of the SignerConfig 244 | func (sc *SignerConfig) SetHashAlgo(ha crypto.Hash) error { 245 | if !sc.HashAlgoIsValid(ha) { 246 | return fmt.Errorf("%s: %w", ha.String(), ErrInvalidHashAlgo) 247 | } 248 | sc.HashAlgo = ha 249 | return nil 250 | } 251 | 252 | // SetHeaderFields sets/override the HeaderFields of the SignerConfig 253 | func (sc *SignerConfig) SetHeaderFields(fl ...string) error { 254 | hf := false 255 | for _, f := range fl { 256 | sc.HeaderFields = append(sc.HeaderFields, f) 257 | if strings.EqualFold(f, "From") { 258 | hf = true 259 | } 260 | } 261 | if !hf { 262 | return ErrFromRequired 263 | } 264 | return nil 265 | } 266 | 267 | // HashAlgoIsValid returns true if a the provided crypto.Hash is a valid algorithm for the SignerConfig 268 | func (sc *SignerConfig) HashAlgoIsValid(ha crypto.Hash) bool { 269 | switch ha.String() { 270 | case "SHA-256": 271 | default: 272 | return false 273 | } 274 | return true 275 | } 276 | 277 | // CanonicalizationIsValid returns true if a the provided Canonicalization is a valid value for the SignerConfig 278 | func (sc *SignerConfig) CanonicalizationIsValid(c dkim.Canonicalization) bool { 279 | switch c { 280 | case "simple", "relaxed": 281 | default: 282 | return false 283 | } 284 | return true 285 | } 286 | 287 | // SetSelector overrides the Selector of the SignerConfig 288 | func (sc *SignerConfig) SetSelector(s string) error { 289 | if s == "" { 290 | return ErrEmptySelector 291 | } 292 | sc.Selector = s 293 | return nil 294 | } 295 | -------------------------------------------------------------------------------- /dkim/config_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package dkim 6 | 7 | import ( 8 | "crypto" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/emersion/go-msgauth/dkim" 14 | ) 15 | 16 | func TestNewConfig(t *testing.T) { 17 | tests := []struct { 18 | n string 19 | d string 20 | s string 21 | f bool 22 | }{ 23 | {"valid domain and selector", TestDomain, TestSelector, false}, 24 | {"valid domain and empty selector", TestDomain, "", true}, 25 | {"empty domain and valid selector", "", TestSelector, true}, 26 | {"empty domain and empty selector", "", "", true}, 27 | } 28 | 29 | for _, tt := range tests { 30 | t.Run(tt.n, func(t *testing.T) { 31 | c, err := NewConfig(tt.d, tt.s) 32 | if err != nil && !tt.f { 33 | t.Errorf("NewConfig failed but was supposed to succeed: %s", err) 34 | } 35 | if c.Domain != tt.d && !tt.f { 36 | t.Errorf("SignerConfig domain incorrect. Expected: %s, got: %s", tt.d, c.Domain) 37 | } 38 | if c.Selector != tt.s && !tt.f { 39 | t.Errorf("SignerConfig selector incorrect. Expected: %s, got: %s", tt.s, c.Selector) 40 | } 41 | }) 42 | } 43 | 44 | // Test nil option 45 | _, err := NewConfig(TestDomain, TestSelector, nil) 46 | if err != nil { 47 | t.Errorf("NewConfig with nil option failed: %s", err) 48 | } 49 | } 50 | 51 | func TestNewConfig_WithSetAUID(t *testing.T) { 52 | a := "testauid" 53 | c, err := NewConfig(TestDomain, TestSelector, WithAUID(a)) 54 | if err != nil { 55 | t.Errorf("NewConfig failed: %s", err) 56 | } 57 | if c.AUID != a { 58 | t.Errorf("WithAUID failed. Expected: %s, got: %s", a, c.AUID) 59 | } 60 | c.SetAUID("auidtest") 61 | if c.AUID != "auidtest" { 62 | t.Errorf("SetAUID failed. Expected: %s, got: %s", "auidtest", c.AUID) 63 | } 64 | } 65 | 66 | func TestNewConfig_WithSetHashAlgo(t *testing.T) { 67 | tests := []struct { 68 | n string 69 | ha crypto.Hash 70 | f bool 71 | }{ 72 | {"SHA-256", crypto.SHA256, false}, 73 | {"SHA-1", crypto.SHA1, true}, 74 | {"MD5", crypto.MD5, true}, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.n, func(t *testing.T) { 79 | c, err := NewConfig(TestDomain, TestSelector, WithHashAlgo(tt.ha)) 80 | if err != nil && !tt.f { 81 | t.Errorf("NewConfig WithHashAlgo failed: %s", err) 82 | } 83 | if c.HashAlgo.String() != tt.ha.String() && !tt.f { 84 | t.Errorf("NewConfig WithHashAlgo failed. Expected algo: %s, got: %s", 85 | tt.ha.String(), c.HashAlgo.String()) 86 | } 87 | 88 | c = nil 89 | c, err = NewConfig(TestDomain, TestSelector) 90 | if err != nil && !tt.f { 91 | t.Errorf("NewConfig WithHashAlgo failed: %s", err) 92 | } 93 | if err := c.SetHashAlgo(tt.ha); err != nil && !tt.f { 94 | t.Errorf("SetHashAlgo failed: %s", err) 95 | } 96 | if c.HashAlgo.String() != tt.ha.String() && !tt.f { 97 | t.Errorf("NewConfig WithHashAlgo failed. Expected algo: %s, got: %s", 98 | tt.ha.String(), c.HashAlgo.String()) 99 | } 100 | }) 101 | } 102 | } 103 | 104 | func TestNewConfig_WitSethCano(t *testing.T) { 105 | c, err := NewConfig(TestDomain, TestSelector, WithHeaderCanonicalization(dkim.CanonicalizationSimple), 106 | WithBodyCanonicalization(dkim.CanonicalizationSimple)) 107 | if err != nil { 108 | t.Errorf("NewConfig failed: %s", err) 109 | } 110 | if c.CanonicalizationHeader != dkim.CanonicalizationSimple { 111 | t.Errorf("WithHeaderCanonicalization failed. Expected: %s, got: %s", dkim.CanonicalizationSimple, 112 | c.CanonicalizationHeader) 113 | } 114 | if c.CanonicalizationBody != dkim.CanonicalizationSimple { 115 | t.Errorf("WithBodyCanonicalization failed. Expected: %s, got: %s", dkim.CanonicalizationSimple, 116 | c.CanonicalizationBody) 117 | } 118 | if err := c.SetHeaderCanonicalization(dkim.CanonicalizationRelaxed); err != nil { 119 | t.Errorf("SetHeaderCanonicalization failed: %s", err) 120 | } 121 | if err := c.SetBodyCanonicalization(dkim.CanonicalizationRelaxed); err != nil { 122 | t.Errorf("SetBodyCanonicalization failed: %s", err) 123 | } 124 | if c.CanonicalizationHeader != dkim.CanonicalizationRelaxed { 125 | t.Errorf("SetHeaderCanonicalization failed. Expected: %s, got: %s", dkim.CanonicalizationRelaxed, 126 | c.CanonicalizationHeader) 127 | } 128 | if c.CanonicalizationBody != dkim.CanonicalizationRelaxed { 129 | t.Errorf("SetBodyCanonicalization failed. Expected: %s, got: %s", dkim.CanonicalizationRelaxed, 130 | c.CanonicalizationBody) 131 | } 132 | if err := c.SetHeaderCanonicalization("invalid"); err == nil { 133 | t.Errorf("SetHeaderCanonicalization was supposed to fail, but didn't") 134 | } 135 | if err := c.SetBodyCanonicalization("invalid"); err == nil { 136 | t.Errorf("SetBodyCanonicalization was supposed to fail, but didn't") 137 | } 138 | } 139 | 140 | func TestNewConfig_WitCanoInvalid(t *testing.T) { 141 | _, err := NewConfig(TestDomain, TestSelector, WithHeaderCanonicalization("invalid")) 142 | if err == nil { 143 | t.Errorf("NewConfig with invalid WithHeaderCanonalization was supposed to fail but didn't") 144 | } 145 | _, err = NewConfig(TestDomain, TestSelector, WithBodyCanonicalization("invalid")) 146 | if err == nil { 147 | t.Errorf("NewConfig with invalid WithBodyCanonalization was supposed to fail but didn't") 148 | } 149 | } 150 | 151 | func TestNewConfig_SetSelector(t *testing.T) { 152 | s := "override_selector" 153 | c, err := NewConfig(TestDomain, TestSelector) 154 | if err != nil { 155 | t.Errorf("NewConfig failed: %s", err) 156 | } 157 | if err := c.SetSelector(s); err != nil { 158 | t.Errorf("SetSelector() failed: %s", err) 159 | } 160 | if c.Selector != s { 161 | t.Errorf("SetSelector failed. Expected: %s, got: %s", s, c.Selector) 162 | } 163 | if err := c.SetSelector(""); err == nil { 164 | t.Errorf("empty string in SetSelector() expected to fail, but did not") 165 | } 166 | } 167 | 168 | func TestNewConfig_WithSetHeaderFields(t *testing.T) { 169 | tests := []struct { 170 | n string 171 | v []string 172 | w []string 173 | f bool 174 | }{ 175 | {"With one header field: From", []string{"From"}, []string{"From"}, false}, 176 | { 177 | "Multiple entries", 178 | []string{"From", "Reply-To", "To"}, 179 | []string{"From", "Reply-To", "To"}, 180 | false, 181 | }, 182 | {"Empty should fail", []string{}, []string{}, true}, 183 | {"With one header field no From", []string{"Reply-To"}, []string{"Reply-To"}, true}, 184 | { 185 | "Multiple entries no From", 186 | []string{"Reply-To", "To"}, 187 | []string{"Reply-To", "To"}, 188 | true, 189 | }, 190 | } 191 | 192 | for _, tt := range tests { 193 | t.Run(tt.n, func(t *testing.T) { 194 | c, err := NewConfig(TestDomain, TestSelector, WithHeaderFields(tt.v...)) 195 | if err != nil && !tt.f { 196 | t.Errorf("NewConfig WithHeaderFeilds failed: %s", err) 197 | } 198 | if len(c.HeaderFields) > 0 { 199 | for n := range c.HeaderFields { 200 | if !strings.EqualFold(c.HeaderFields[n], tt.w[n]) && !tt.f { 201 | t.Errorf("NewConfig WithHeaderFields failed. Expected: %s, got: %s", 202 | tt.w[n], c.HeaderFields[n]) 203 | } 204 | } 205 | } 206 | 207 | c = nil 208 | c, err = NewConfig(TestDomain, TestSelector) 209 | if err != nil && !tt.f { 210 | t.Errorf("NewConfig WithHeaderFields failed: %s", err) 211 | } 212 | if err := c.SetHeaderFields(tt.v...); err != nil && !tt.f { 213 | t.Errorf("SetHeaderFields failed: %s", err) 214 | } 215 | if len(c.HeaderFields) > 0 { 216 | for n := range c.HeaderFields { 217 | if !strings.EqualFold(c.HeaderFields[n], tt.w[n]) && !tt.f { 218 | t.Errorf("SetHeaderFields failed. Expected: %s, got: %s", 219 | tt.w[n], c.HeaderFields[n]) 220 | } 221 | } 222 | } 223 | }) 224 | } 225 | } 226 | 227 | func TestNewConfig_WithExpiration(t *testing.T) { 228 | vt := time.Now().Add(time.Hour) 229 | wt := time.Now().Add(time.Hour * -24) 230 | 231 | // Valid time 232 | c, err := NewConfig(TestDomain, TestSelector, WithExpiration(vt)) 233 | if err != nil { 234 | t.Errorf("NewConfig failed: %s", err) 235 | } 236 | _ = c 237 | c, err = NewConfig(TestDomain, TestSelector, WithExpiration(wt)) 238 | if err == nil { 239 | t.Errorf("NewConfig with wrong epxiration was expected to fail, but didn't") 240 | } 241 | if err := c.SetExpiration(vt.Add(time.Hour)); err != nil { 242 | t.Errorf("SetExpiration() failed: %s", err) 243 | } 244 | if c.Expiration.Unix() != vt.Add(time.Hour).Unix() { 245 | t.Errorf("SetExpiration failed. Expected: %d, got: %d", vt.Add(time.Hour).Unix(), c.Expiration.Unix()) 246 | } 247 | if err := c.SetExpiration(wt); err == nil { 248 | t.Errorf("yesterday as value for SetExpiration() expected to fail, but did not") 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /dkim/dkim.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package dkim 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "crypto" 11 | "crypto/ed25519" 12 | "crypto/rand" 13 | "crypto/x509" 14 | "encoding/pem" 15 | "errors" 16 | "fmt" 17 | "io" 18 | "strings" 19 | 20 | "github.com/emersion/go-msgauth/dkim" 21 | "github.com/wneessen/go-mail" 22 | ) 23 | 24 | // Middleware is the middleware struct for the DKIM middleware 25 | type Middleware struct { 26 | so *dkim.SignOptions 27 | } 28 | 29 | // Type is the type of Middleware 30 | const Type mail.MiddlewareType = "dkim" 31 | 32 | var ( 33 | ErrInvalidHashAlgo = errors.New("unsupported hashing algorithm") 34 | ErrInvalidCanonicalization = errors.New("unsupported canonicalization type") 35 | ErrDecodePEMFailed = errors.New("failed to decode PEM block") 36 | ErrNotEd25519Key = errors.New("provided key is not of type Ed25519") 37 | ErrInvalidExpiration = errors.New("expiration date must be in the future") 38 | ErrEmptySelector = errors.New("DKIM domain selector must not be empty") 39 | ErrFromRequired = errors.New(`the "From" field is required`) 40 | ) 41 | 42 | // NewFromRSAKey returns a new Middlware from a given RSA private key 43 | // byte slice and a SignerConfig 44 | func NewFromRSAKey(k []byte, sc *SignerConfig) (*Middleware, error) { 45 | dp, _ := pem.Decode(k) 46 | if dp == nil { 47 | return nil, ErrDecodePEMFailed 48 | } 49 | pk, err := x509.ParsePKCS1PrivateKey(dp.Bytes) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to parse private key: %w", err) 52 | } 53 | return newMiddleware(sc, pk) 54 | } 55 | 56 | // NewFromEd25519Key returns a new Signer instance from a given PEM encoded Ed25519 57 | // private key 58 | func NewFromEd25519Key(k []byte, sc *SignerConfig) (*Middleware, error) { 59 | var pk ed25519.PrivateKey 60 | dp, _ := pem.Decode(k) 61 | if dp == nil { 62 | return nil, ErrDecodePEMFailed 63 | } 64 | apk, err := x509.ParsePKCS8PrivateKey(dp.Bytes) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to parse private key: %w", err) 67 | } 68 | switch tpk := apk.(type) { 69 | case ed25519.PrivateKey: 70 | pk = tpk 71 | default: 72 | return nil, ErrNotEd25519Key 73 | } 74 | return newMiddleware(sc, pk) 75 | } 76 | 77 | // Handle is the handler method that satisfies the mail.Middleware interface 78 | func (d Middleware) Handle(m *mail.Msg) *mail.Msg { 79 | // If no boundary is set for the mail.Msg we need to set our own fixed boundary, otherwise 80 | // a new boundary will bet generated after the middleware has been applied and therfore 81 | // the body hash will be altered 82 | // TODO: Add a GetBoundary() method to go-mail, so we don't override a already set boundary 83 | m.SetBoundary(randomBoundary()) 84 | ibuf := bytes.NewBuffer(nil) 85 | _, err := m.WriteToSkipMiddleware(ibuf, Type) 86 | if err != nil { 87 | return m 88 | } 89 | 90 | var obuf bytes.Buffer 91 | if err := dkim.Sign(&obuf, ibuf, d.so); err != nil { 92 | return m 93 | } 94 | br := bufio.NewReader(&obuf) 95 | h, err := extractDKIMHeader(br) 96 | if err != nil { 97 | return m 98 | } 99 | if h != "" { 100 | m.SetGenHeaderPreformatted("DKIM-Signature", h) 101 | } 102 | return m 103 | } 104 | 105 | // Type returns the MiddlewareType for this Middleware 106 | func (d Middleware) Type() mail.MiddlewareType { 107 | return Type 108 | } 109 | 110 | // new returns a new Middleware and can be used with the mail.WithMiddleware method. 111 | // It takes a SignerConfig and a crypto.Signer as arguments. 112 | // 113 | // This method is invoked by the different New*() methods 114 | func newMiddleware(sc *SignerConfig, cs crypto.Signer) (*Middleware, error) { 115 | so := &dkim.SignOptions{ 116 | Domain: sc.Domain, 117 | Selector: sc.Selector, 118 | Identifier: sc.AUID, 119 | Signer: cs, 120 | Hash: sc.HashAlgo, 121 | HeaderCanonicalization: sc.CanonicalizationHeader, 122 | BodyCanonicalization: sc.CanonicalizationBody, 123 | HeaderKeys: sc.HeaderFields, 124 | Expiration: sc.Expiration, 125 | } 126 | 127 | return &Middleware{so: so}, nil 128 | } 129 | 130 | // extractDKIMHeader is a helper method to extract the generated DKIM mail header 131 | // from output of the mail.Msg 132 | func extractDKIMHeader(br *bufio.Reader) (string, error) { 133 | var h []string 134 | for { 135 | l, err := br.ReadString('\n') 136 | if err != nil { 137 | switch { 138 | case errors.Is(err, io.EOF): 139 | break 140 | default: 141 | return "", fmt.Errorf("failed to parse mail message header: %w", err) 142 | } 143 | } 144 | if len(l) == 0 { 145 | break 146 | } 147 | if len(l) == 2 && (l[0] == '\r' && l[1] == '\n') { 148 | break 149 | } 150 | if len(h) > 0 && (l[0] == ' ' || l[0] == '\t') { 151 | h[len(h)-1] += l 152 | } else { 153 | h = append(h, l) 154 | } 155 | } 156 | for i := range h { 157 | s := strings.SplitN(h[i], ": ", 2) 158 | if len(s) == 2 && s[0] == "DKIM-Signature" { 159 | hv := s[1] 160 | hv = strings.TrimRight(hv, mail.SingleNewLine) 161 | return hv, nil 162 | } 163 | } 164 | return "", nil 165 | } 166 | 167 | // randomBoundary generates boundary in case no boundary is set yet 168 | func randomBoundary() string { 169 | var buf [30]byte 170 | _, err := io.ReadFull(rand.Reader, buf[:]) 171 | if err != nil { 172 | panic(err) 173 | } 174 | return fmt.Sprintf("%x", buf[:]) 175 | } 176 | -------------------------------------------------------------------------------- /dkim/dkim_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package dkim 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "crypto" 11 | "crypto/ecdsa" 12 | "crypto/ed25519" 13 | "crypto/x509" 14 | "encoding/pem" 15 | "strings" 16 | "testing" 17 | 18 | "github.com/wneessen/go-mail" 19 | ) 20 | 21 | const ( 22 | TestDomain = "test.tld" 23 | TestSelector = "mail" 24 | ) 25 | 26 | const ( 27 | rsaTestKey = `-----BEGIN RSA PRIVATE KEY----- 28 | MIICXQIBAAKBgQDrR8LgINQIN+jUkt0+OYFlDqf4hT10x9jRUMMg/NrcG/h5mP9B 29 | 7KU2TGUIt3ItetSB/ltfaIsOeEtns2eAGVzz77cQodWC9qWkYbuou9xQNbL2jNFF 30 | aFA30p5E8iupp9dndm2nJXws5EjCp/JEYRGeYW7kgAWFNvDFnTng7M1lXQIDAQAB 31 | AoGAW2F90OsvLxn39kgsYfSXyxZMKvwlCGxuS63ge7l5j6/Va/T+fy5YZKR7QU1u 32 | rTddvjd6aa4DBFW4g8hsVJaFQQKVRngIK5pMCk6wrBVW1glCAKeQ1ie2bZt0LvYs 33 | 9HLnthpaZxU/eaFpgwUvmZVPgV1uLRe4MxeotHi9cW27PUECQQD6eOHmCHnd6pmx 34 | MBj5/xL86x3Ldyf/axyUC7SUIotIzsbkrmd6PSjFENFAKvTU/oOdleVpyAAgw92e 35 | Ykey+NAlAkEA8HkMOUUk6RpCPTe3M76XMaje9Hf3yinyIZG3BjILue402rfaJ0m6 36 | eRmGcsuRO5CIezz2GL3dHCvwfU3kOMw+2QJBAMH0a5FSzPPgX+VKhnzIXa7GbksJ 37 | WUq7aeTmb44qdcsKfA/HUc/hnjmDvVXALdjlwYt88KqKOjclFO850aWwcJUCQA0M 38 | RGGHIu2TAy0XLNWd7c4//3j8WXGavQydP3USmhhImI2VlDy1f2y6udTYvtSgjwdA 39 | 04mcI7c3myDxbQS38GECQQDnZMASDyQE+/CK8plckVrGGcy+X/8EGta+HeK0ZH3E 40 | UDKil5X2rYZq+ADN7yEYh9f9i9da/ngzkaog1TvcLqpJ 41 | -----END RSA PRIVATE KEY-----` 42 | 43 | ed25519TestKey = `-----BEGIN PRIVATE KEY----- 44 | MC4CAQAwBQYDK2VwBCIEIPEZCuuDQ2PIH1RDbMl92DIb8Vsqz2j7B26aHomVq1pU 45 | -----END PRIVATE KEY-----` 46 | 47 | rsaTestKeyPKCS8 = `-----BEGIN PRIVATE KEY----- 48 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCqUtV4PV2kTmkW 49 | ti9PxJ0atHVu7Jf5zMNMHNy+prWCqSqDlz8Tz6weRiuP7+a7vGliCQHr02etzz0r 50 | lPJ+tTXw/18/B49+1BWu3ves2d7N67IIziRIOXkxdSjcfgDqzXrUmtnhMp8nVn1V 51 | YPavr2J1OSuA5sBz3C5GFddurFeR5a3C1BEsqEU0BJtbwX2dreNWGvznK2WgP+nR 52 | 1K78uoAsVsviUEqaYvo8Ipvq7+oP2JQqjj7AKZ6JNaOSl9vc0MY89bveSW7NksyJ 53 | W2YNAuGxhkbPzjYu/8UwPv7vS/zTZjzZ55d5MDVwafqVWNOqxmqLPKO4D2x6HaQ6 54 | WVnmkcq7AgMBAAECggEABT0IoFWW1Vbgbliw1NH+h+kZRtqGlSHu+1ploR5GnCAO 55 | jHJoPDQd0ZF62sbxqy8VUkGqVNP1hFSvJU9/q0S1Ciu0QHTqXyOFUxGzWNqB5YZM 56 | 8H+n59Bb6CnvRFsxeY/LB+NC8Vy7xxtiggl+gzT0uqArif29yCWmfo5Tv4bx4vFL 57 | bv04dm5JIcWpsmBuXVQcjRI1axmwbBATvMagQ/iwEB5OHxG0Z5Xf/c6ivEDeVkVX 58 | +CIDyMmj2wcqz0Ao98x1IOUtN1c6HTD1FaeLJHFg2l2aj6RBwcTFWfEpjHIQz3Ul 59 | oe7FJxwi9RefoX/KNGmv46zc0Jssx3ZuPg0KjH00eQKBgQDLu9osJc+MfLw2vKlF 60 | nwb+V8gf7cYZqw6fFLhbpugKe/Y/8lbvgmBUp19wEcGeD770MtY8NWVqEzqSOYkg 61 | JQF9sxjOIotqad0ZAwYohVM9hHIcaMCgDfRPFYrrz5s6mSE/PIAr1bnAFO0LLChj 62 | pcCHPi+dNzvpkPEODBCBui8PAwKBgQDWBMa6w9GXu7l2i64clU6EWgPrymKXxBq2 63 | 2m218r3B5ZJiKet+hHF3wC2w6kv0TAGdi3fj6FizVRqofQrlMgIydT2YvOjTByaJ 64 | nXYupGvE6KLGQGgfIf1Tv0k+8cv04AlJLI2xnijPc+A2vDpnJU0B5tzcfS2ctsSa 65 | 7dFY/AgL6QKBgH/0Eyn29Ur+bBbUllsrbXEAIKgs5WXpkN1IXiDxynoLMLUotoDm 66 | GSoRlFcGT9u9d+hWpUZbIr5kJT0A9aZCl5UijkmoWHcU1c+Hnq6ETastK528DH55 67 | RR8GIKHJWWyMD91vWfAt4uNIQTfrG9K5nxlRbQYIUpB2f26bFSLkk/mRAoGAZtI4 68 | n/YANkPMYLXO2pCo/lE43QmIwJ1IsFzUpLuQix0+bMbzCv+afAvqZ7rI7v+tLwGY 69 | gfhY1R+oBRa+K0sRXyiQhVcNDIW88BSkeNgpppqVyWWcIIj16kxWZlVIxcb07yDm 70 | mlUAClsDd4iLDo8PJkDCD3Rce5QbdMuY7oV3YDECgYA/nf2B5Qo2im4w9GiCMYIr 71 | E6IylAS2062WYGJVnSNrcfWn8uO9Z2VSNCwTpsvdTxugpe5e8kLHr2BbLypUyyau 72 | wJzNCYNbFNw2GX2AE4G9bjGigkRfzOzG465xsZ178EgqW05MFtdNSSSUyvNMdJtb 73 | hcSTp1LpV7OWf4eUXzgnZQ== 74 | -----END PRIVATE KEY-----` 75 | 76 | // This is not supported and therefore will be used as invalid key 77 | ecdsaTestKey = `-----BEGIN EC PRIVATE KEY----- 78 | MHcCAQEEIPU37mgOoRosvJn/VUoHgZS8WeeU5kNBaLOFbE0sSneioAoGCCqGSM49 79 | AwEHoUQDQgAEjPEx4l9YpiLIBY1uLyQQF8nctRSy3r2A3G3buEJTxIjFXHryJV5o 80 | ZLBL5rRTspkS5R2YTrEgaqBXFhKz4lQdbg== 81 | -----END EC PRIVATE KEY-----` 82 | ) 83 | 84 | func TestNewSigner(t *testing.T) { 85 | confOk := &SignerConfig{Domain: TestDomain, Selector: TestSelector, HashAlgo: crypto.SHA256} 86 | confNoDomain := &SignerConfig{Selector: TestSelector} 87 | confNoSelector := &SignerConfig{Domain: TestDomain} 88 | confNoHashAlgo := &SignerConfig{Domain: TestDomain, Selector: TestSelector} 89 | confInvalidAlgo := &SignerConfig{Domain: TestDomain, Selector: TestSelector, HashAlgo: crypto.MD5} 90 | confInvalidCano := &SignerConfig{ 91 | Domain: TestDomain, Selector: TestSelector, HashAlgo: crypto.SHA256, 92 | CanonicalizationHeader: "invalid", CanonicalizationBody: "invalid", 93 | } 94 | confEmpty := &SignerConfig{} 95 | 96 | var ipk *ecdsa.PrivateKey 97 | var epk ed25519.PrivateKey 98 | p, _ := pem.Decode([]byte(rsaTestKey)) 99 | rpk, err := x509.ParsePKCS1PrivateKey(p.Bytes) 100 | if err != nil { 101 | t.Errorf("failed to parse private RSA key: %s", err) 102 | return 103 | } 104 | p, _ = pem.Decode([]byte(ed25519TestKey)) 105 | apk, err := x509.ParsePKCS8PrivateKey(p.Bytes) 106 | if err != nil { 107 | t.Errorf("failed to parse private RSA key: %s", err) 108 | return 109 | } 110 | switch cpk := apk.(type) { 111 | case ed25519.PrivateKey: 112 | epk = cpk 113 | default: 114 | t.Errorf("given key is not a Ed25519 private key") 115 | return 116 | } 117 | p, _ = pem.Decode([]byte(ecdsaTestKey)) 118 | apk, err = x509.ParseECPrivateKey(p.Bytes) 119 | if err != nil { 120 | t.Errorf("failed to parse private RSA key: %s", err) 121 | return 122 | } 123 | switch cpk := apk.(type) { 124 | case *ecdsa.PrivateKey: 125 | ipk = cpk 126 | default: 127 | t.Errorf("give key is not a ECDSA private key") 128 | return 129 | } 130 | 131 | tests := []struct { 132 | n string 133 | sk crypto.Signer 134 | c *SignerConfig 135 | f bool 136 | }{ 137 | {"RSA: valid domain and selector and hash algo", rpk, confOk, false}, 138 | {"Ed25519: valid domain and selector and hash algo", epk, confOk, false}, 139 | {"ECDSA/Invalid: valid domain and selector and hash algo", ipk, confOk, true}, 140 | {"RSA: valid domain and empty selector", rpk, confNoSelector, true}, 141 | {"RSA: empty domain and valid selector", rpk, confNoDomain, true}, 142 | {"RSA: valid domain, valid selector, no hash algo", rpk, confNoHashAlgo, true}, 143 | {"RSA: valid domain, valid selector, valid hash algo, invalid cano", rpk, confInvalidCano, true}, 144 | {"RSA: valid domain, valid selector, invalid hash algo", rpk, confInvalidAlgo, true}, 145 | {"RSA: empty config", rpk, confEmpty, true}, 146 | } 147 | for _, tt := range tests { 148 | t.Run(tt.n, func(t *testing.T) { 149 | s, err := newMiddleware(tt.c, tt.sk) 150 | if err != nil && !tt.f { 151 | t.Errorf("NewSigner failed but was supposed to succeed: %s", err) 152 | } 153 | if s == nil && !tt.f { 154 | t.Errorf("NewSigner response is nil") 155 | return 156 | } 157 | }) 158 | } 159 | } 160 | 161 | func TestNewFromRSAKey(t *testing.T) { 162 | c := &SignerConfig{ 163 | Domain: TestDomain, 164 | Selector: TestSelector, 165 | HashAlgo: crypto.SHA256, 166 | } 167 | _, err := NewFromRSAKey([]byte(rsaTestKey), c) 168 | if err != nil { 169 | t.Errorf("NewFromRSAKey failed: %s", err) 170 | } 171 | _, err = NewFromRSAKey([]byte(ed25519TestKey), c) 172 | if err == nil { 173 | t.Errorf("NewFromRSAKey was supposed to fail, but didn't") 174 | } 175 | _, err = NewFromRSAKey([]byte(ed25519TestKey), nil) 176 | if err == nil { 177 | t.Errorf("NewFromRSAKey was supposed to fail, but didn't") 178 | } 179 | _, err = NewFromRSAKey([]byte("foo"), c) 180 | if err == nil { 181 | t.Errorf("NewFromRSAKey was supposed to fail, but didn't") 182 | } 183 | } 184 | 185 | func TestNewFromEd25519Key(t *testing.T) { 186 | c := &SignerConfig{ 187 | Domain: TestDomain, 188 | Selector: TestSelector, 189 | HashAlgo: crypto.SHA256, 190 | } 191 | _, err := NewFromEd25519Key([]byte(ed25519TestKey), c) 192 | if err != nil { 193 | t.Errorf("NewFromEd25519Key failed: %s", err) 194 | } 195 | _, err = NewFromEd25519Key([]byte(rsaTestKey), c) 196 | if err == nil { 197 | t.Errorf("NewFromEd25519Key was supposed to fail, but didn't") 198 | } 199 | _, err = NewFromEd25519Key([]byte(rsaTestKey), nil) 200 | if err == nil { 201 | t.Errorf("NewFromEd25519Key was supposed to fail, but didn't") 202 | } 203 | _, err = NewFromEd25519Key([]byte(rsaTestKeyPKCS8), c) 204 | if err == nil { 205 | t.Errorf("NewFromEd25519Key was supposed to fail, but didn't") 206 | } 207 | _, err = NewFromEd25519Key([]byte("foo"), c) 208 | if err == nil { 209 | t.Errorf("NewFromEd25519Key was supposed to fail, but didn't") 210 | } 211 | } 212 | 213 | func TestMiddleware_Type(t *testing.T) { 214 | co, err := NewConfig(TestDomain, TestSelector) 215 | if err != nil { 216 | t.Errorf("failed to generate new config: %s", err) 217 | } 218 | m, err := NewFromRSAKey([]byte(rsaTestKey), co) 219 | if err != nil { 220 | t.Errorf("failed to generate new middleware: %s", err) 221 | } 222 | if m.Type() != Type { 223 | t.Errorf("Type() failed. Expected: %s, got: %s", Type, m.Type()) 224 | } 225 | } 226 | 227 | func TestMiddleware_Handle(t *testing.T) { 228 | co, err := NewConfig(TestDomain, TestSelector) 229 | if err != nil { 230 | t.Errorf("failed to generate new config: %s", err) 231 | } 232 | mw, err := NewFromRSAKey([]byte(rsaTestKey), co) 233 | if err != nil { 234 | t.Errorf("failed to generate new middleware: %s", err) 235 | } 236 | 237 | m := mail.NewMsg(mail.WithMiddleware(mw)) 238 | m.Subject("This is a subject") 239 | m.SetDate() 240 | m.SetBodyString(mail.TypeTextPlain, "This is the mail body") 241 | buf := bytes.Buffer{} 242 | _, err = m.WriteTo(&buf) 243 | if err != nil { 244 | t.Errorf("failed writing message to memory: %s", err) 245 | } 246 | } 247 | 248 | func TestExtractDKIMHeader(t *testing.T) { 249 | co, err := NewConfig(TestDomain, TestSelector) 250 | if err != nil { 251 | t.Errorf("failed to generate new config: %s", err) 252 | } 253 | mw, err := NewFromRSAKey([]byte(rsaTestKey), co) 254 | if err != nil { 255 | t.Errorf("failed to generate new middleware: %s", err) 256 | } 257 | m := mail.NewMsg(mail.WithMiddleware(mw)) 258 | m.Subject("This is a subject") 259 | m.SetDate() 260 | m.SetBodyString(mail.TypeTextPlain, "This is the mail body") 261 | br := bufio.NewReader(m.NewReader()) 262 | sig, err := extractDKIMHeader(br) 263 | if err != nil { 264 | t.Errorf("failed to extract DKIM header: %s", err) 265 | } 266 | if !strings.HasPrefix(sig, "a=rsa-sha256") { 267 | t.Errorf("extractDKIMHeader failed. Expected prefix not found") 268 | } 269 | m = &mail.Msg{} 270 | br = bufio.NewReader(m.NewReader()) 271 | _, err = extractDKIMHeader(br) 272 | if err != nil { 273 | t.Errorf("failed to extract DKIM header: %s", err) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | module github.com/wneessen/go-mail-middleware 6 | 7 | go 1.23.0 8 | 9 | require ( 10 | github.com/ProtonMail/gopenpgp/v2 v2.9.0 11 | github.com/emersion/go-msgauth v0.7.0 12 | github.com/wneessen/go-mail v0.6.2 13 | golang.org/x/text v0.25.0 14 | ) 15 | 16 | require ( 17 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 18 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect 19 | github.com/cloudflare/circl v1.6.0 // indirect 20 | github.com/pkg/errors v0.9.1 // indirect 21 | golang.org/x/crypto v0.35.0 // indirect 22 | golang.org/x/sys v0.30.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 2 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 3 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= 4 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 5 | github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE= 6 | github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s= 7 | github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 8 | github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 9 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= 12 | github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 19 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 20 | github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8= 21 | github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4= 22 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 23 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 24 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 25 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 26 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 27 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 28 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 29 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 30 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 31 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 32 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 33 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 34 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 35 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 36 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 38 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 39 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 40 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 41 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 42 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 43 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 44 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 45 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 48 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 49 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 50 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 51 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 52 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 60 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 62 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 63 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 64 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 65 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 66 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 67 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 68 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 69 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 70 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 71 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 72 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 73 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 74 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 75 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 76 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 77 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 78 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 79 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 80 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 81 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 82 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 83 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 84 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 85 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 86 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 87 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 88 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 89 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 90 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | -------------------------------------------------------------------------------- /go.sum.license: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Copyright (c) 2024 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | // Package log implements a convenient wrapper for the Go stdlib log.Logger that can 6 | // used in the different go-mail-middleware modules 7 | package log 8 | 9 | import ( 10 | "fmt" 11 | "io" 12 | "log" 13 | ) 14 | 15 | // Level is a type wrapper for an int 16 | type Level int 17 | 18 | // Logger represents the main Logger type 19 | type Logger struct { 20 | p string 21 | l Level 22 | err *log.Logger 23 | warn *log.Logger 24 | info *log.Logger 25 | debug *log.Logger 26 | } 27 | 28 | const ( 29 | // LevelError is the Level for only ERROR log messages 30 | LevelError Level = iota 31 | // LevelWarn is the Level for WARN and higher log messages 32 | LevelWarn 33 | // LevelInfo is the Level for INFO and higher log messages 34 | LevelInfo 35 | // LevelDebug is the Level for DEBUG and higher log messages 36 | LevelDebug 37 | ) 38 | 39 | // New returns a new log.Logger type for the corresponding Middleware to use 40 | func New(o io.Writer, p string, l Level) *Logger { 41 | lf := log.Lmsgprefix | log.LstdFlags 42 | return &Logger{ 43 | l: l, 44 | p: p, 45 | err: log.New(o, fmt.Sprintf("[%s] ERROR: ", p), lf), 46 | warn: log.New(o, fmt.Sprintf("[%s] WARN: ", p), lf), 47 | info: log.New(o, fmt.Sprintf("[%s] INFO: ", p), lf), 48 | debug: log.New(o, fmt.Sprintf("[%s] DEBUG: ", p), lf), 49 | } 50 | } 51 | 52 | // Debug performs a print() on the debug logger 53 | func (l *Logger) Debug(v ...interface{}) { 54 | if l.l >= LevelDebug { 55 | _ = l.debug.Output(2, fmt.Sprint(v...)) 56 | } 57 | } 58 | 59 | // Info performs a print() on the info logger 60 | func (l *Logger) Info(v ...interface{}) { 61 | if l.l >= LevelInfo { 62 | _ = l.info.Output(2, fmt.Sprint(v...)) 63 | } 64 | } 65 | 66 | // Warn performs a print() on the warn logger 67 | func (l *Logger) Warn(v ...interface{}) { 68 | if l.l >= LevelWarn { 69 | _ = l.warn.Output(2, fmt.Sprint(v...)) 70 | } 71 | } 72 | 73 | // Error performs a print() on the error logger 74 | func (l *Logger) Error(v ...interface{}) { 75 | if l.l >= LevelError { 76 | _ = l.err.Output(2, fmt.Sprint(v...)) 77 | } 78 | } 79 | 80 | // Debugf performs a Printf() on the debug logger 81 | func (l *Logger) Debugf(f string, v ...interface{}) { 82 | if l.l >= LevelDebug { 83 | _ = l.debug.Output(2, fmt.Sprintf(f, v...)) 84 | } 85 | } 86 | 87 | // Infof performs a Printf() on the info logger 88 | func (l *Logger) Infof(f string, v ...interface{}) { 89 | if l.l >= LevelInfo { 90 | _ = l.info.Output(2, fmt.Sprintf(f, v...)) 91 | } 92 | } 93 | 94 | // Warnf performs a Printf() on the warn logger 95 | func (l *Logger) Warnf(f string, v ...interface{}) { 96 | if l.l >= LevelWarn { 97 | _ = l.warn.Output(2, fmt.Sprintf(f, v...)) 98 | } 99 | } 100 | 101 | // Errorf performs a Printf() on the error logger 102 | func (l *Logger) Errorf(f string, v ...interface{}) { 103 | if l.l >= LevelError { 104 | _ = l.err.Output(2, fmt.Sprintf(f, v...)) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /log/log_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package log 6 | 7 | import ( 8 | "bytes" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestNew(t *testing.T) { 14 | var b bytes.Buffer 15 | l := New(&b, "test", LevelDebug) 16 | 17 | if l.p != "test" { 18 | t.Error("Expected prefix to be test, got ", l.p) 19 | } 20 | if l.l != LevelDebug { 21 | t.Error("Expected level to be LevelDebug, got ", l.l) 22 | } 23 | if l.err == nil || l.warn == nil || l.info == nil || l.debug == nil { 24 | t.Error("Loggers not initialized") 25 | } 26 | } 27 | 28 | func TestDebug(t *testing.T) { 29 | var b bytes.Buffer 30 | l := New(&b, "test", LevelDebug) 31 | 32 | l.Debug("test") 33 | expected := "[test] DEBUG: test\n" 34 | if !strings.HasSuffix(b.String(), expected) { 35 | t.Errorf("Expected %q, got %q", expected, b.String()) 36 | } 37 | 38 | b.Reset() 39 | l.l = LevelInfo 40 | l.Debug("test") 41 | if b.String() != "" { 42 | t.Error("Debug message was not expected to be logged") 43 | } 44 | } 45 | 46 | func TestDebugf(t *testing.T) { 47 | var b bytes.Buffer 48 | l := New(&b, "test", LevelDebug) 49 | 50 | l.Debugf("test %s", "foo") 51 | expected := "[test] DEBUG: test foo\n" 52 | if !strings.HasSuffix(b.String(), expected) { 53 | t.Errorf("Expected %q, got %q", expected, b.String()) 54 | } 55 | 56 | b.Reset() 57 | l.l = LevelInfo 58 | l.Debugf("test %s", "foo") 59 | if b.String() != "" { 60 | t.Error("Debug message was not expected to be logged") 61 | } 62 | } 63 | 64 | func TestInfo(t *testing.T) { 65 | var b bytes.Buffer 66 | l := New(&b, "test", LevelInfo) 67 | 68 | l.Info("test") 69 | expected := "[test] INFO: test\n" 70 | if !strings.HasSuffix(b.String(), expected) { 71 | t.Errorf("Expected %q, got %q", expected, b.String()) 72 | } 73 | 74 | b.Reset() 75 | l.l = LevelWarn 76 | l.Info("test") 77 | if b.String() != "" { 78 | t.Error("Info message was not expected to be logged") 79 | } 80 | } 81 | 82 | func TestInfof(t *testing.T) { 83 | var b bytes.Buffer 84 | l := New(&b, "test", LevelInfo) 85 | 86 | l.Infof("test %s", "foo") 87 | expected := "[test] INFO: test foo\n" 88 | if !strings.HasSuffix(b.String(), expected) { 89 | t.Errorf("Expected %q, got %q", expected, b.String()) 90 | } 91 | 92 | b.Reset() 93 | l.l = LevelWarn 94 | l.Infof("test %s", "foo") 95 | if b.String() != "" { 96 | t.Error("Info message was not expected to be logged") 97 | } 98 | } 99 | 100 | func TestWarn(t *testing.T) { 101 | var b bytes.Buffer 102 | l := New(&b, "test", LevelWarn) 103 | 104 | l.Warn("test") 105 | expected := "[test] WARN: test\n" 106 | if !strings.HasSuffix(b.String(), expected) { 107 | t.Errorf("Expected %q, got %q", expected, b.String()) 108 | } 109 | 110 | b.Reset() 111 | l.l = LevelError 112 | l.Warn("test") 113 | if b.String() != "" { 114 | t.Error("Warn message was not expected to be logged") 115 | } 116 | } 117 | 118 | func TestWarnf(t *testing.T) { 119 | var b bytes.Buffer 120 | l := New(&b, "test", LevelWarn) 121 | 122 | l.Warnf("test %s", "foo") 123 | expected := "[test] WARN: test foo\n" 124 | if !strings.HasSuffix(b.String(), expected) { 125 | t.Errorf("Expected %q, got %q", expected, b.String()) 126 | } 127 | 128 | b.Reset() 129 | l.l = LevelError 130 | l.Warnf("test %s", "foo") 131 | if b.String() != "" { 132 | t.Error("Warn message was not expected to be logged") 133 | } 134 | } 135 | 136 | func TestError(t *testing.T) { 137 | var b bytes.Buffer 138 | l := New(&b, "test", LevelError) 139 | 140 | l.Error("test") 141 | expected := "[test] ERROR: test\n" 142 | if !strings.HasSuffix(b.String(), expected) { 143 | t.Errorf("Expected %q, got %q", expected, b.String()) 144 | } 145 | } 146 | 147 | func TestErrorf(t *testing.T) { 148 | var b bytes.Buffer 149 | l := New(&b, "test", LevelError) 150 | 151 | l.Errorf("test %s", "foo") 152 | expected := "[test] ERROR: test foo\n" 153 | if !strings.HasSuffix(b.String(), expected) { 154 | t.Errorf("Expected %q, got %q", expected, b.String()) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /openpgp/README.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ## Use with caution 9 | 10 | While this middleware is mostly complete, it has not been properly tested by a large user base 11 | and their corresponding edge-cases. Please keep this in mind when using this middlware. 12 | work, you will need the main branch of the go-mail package. The latest releases do not provide 13 | all the functionality required for this middleware to work. 14 | 15 | ## OpenPGP middleware 16 | 17 | This middleware allows to encrypt the mail body and the attachments of a go-mail `*Msg` 18 | before sending it. 19 | 20 | ### PGP Schme support 21 | 22 | This middleware supports two PGP encoding schemes: 23 | * PGP/Inline 24 | * PGP/MIME 25 | 26 | *Please note, that PGP/Inline does only work with plain text mails. Any mail message 27 | (alternative) body part of type `text/html` will be discarded in the final output 28 | of the mail.* 29 | 30 | ### Example 31 | 32 | ```go 33 | package main 34 | 35 | import ( 36 | "fmt" 37 | "log" 38 | "os" 39 | 40 | "github.com/wneessen/go-mail" 41 | "github.com/wneessen/go-mail-middleware/openpgp" 42 | ) 43 | 44 | const pubKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- 45 | [...] 46 | -----END PGP PUBLIC KEY BLOCK-----` 47 | 48 | func main() { 49 | // First we need a config for our OpenPGP middleware 50 | // 51 | // In case your public key is in byte slice format or even a file, we provide two 52 | // helper methods: 53 | // - openpgp.NewConfigFromPubKeyBytes() 54 | // - openpgp.NewConfigFromPubKeyFile() 55 | mc, err := openpgp.NewConfig(pubKey, openpgp.WithScheme(openpgp.SchemePGPInline)) 56 | if err != nil { 57 | fmt.Printf("failed to create new config: %s\n", err) 58 | os.Exit(1) 59 | } 60 | mw := openpgp.NewMiddleware(mc) 61 | 62 | // Finally we create a new mail.Msg with our middleware assigned 63 | m := mail.NewMsg(mail.WithMiddleware(mw)) 64 | if err := m.From("toni.sender@example.com"); err != nil { 65 | log.Fatalf("failed to set From address: %s", err) 66 | } 67 | if err := m.To("tina.recipient@example.com"); err != nil { 68 | log.Fatalf("failed to set To address: %s", err) 69 | } 70 | m.Subject("This is my first mail with go-mail!") 71 | m.SetBodyString(mail.TypeTextPlain, "Do you like this mail? I certainly do!") 72 | c, err := mail.NewClient("smtp.example.com", mail.WithPort(25), 73 | mail.WithSMTPAuth(mail.SMTPAuthPlain), 74 | mail.WithUsername("my_username"), mail.WithPassword("extremely_secret_pass")) 75 | if err != nil { 76 | log.Fatalf("failed to create mail client: %s", err) 77 | } 78 | if err := c.DialAndSend(m); err != nil { 79 | log.Fatalf("failed to send mail: %s", err) 80 | } 81 | } 82 | ``` -------------------------------------------------------------------------------- /openpgp/config.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package openpgp 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "os" 11 | 12 | "github.com/wneessen/go-mail-middleware/log" 13 | ) 14 | 15 | // PGPScheme is an alias type for an int 16 | type PGPScheme int 17 | 18 | // Action is an alias type for an int 19 | type Action int 20 | 21 | const ( 22 | // SchemePGPInline represents the PGP/Inline scheme 23 | // 24 | // Note: Inline PGP only supports plain text mails. Content bodies of type 25 | // HTML (or alternative body parts of the same type) will be ignored 26 | SchemePGPInline PGPScheme = iota 27 | // SchemePGPMIME represents the OpenPGP/MIME (RFC 4880 and 3156) scheme 28 | SchemePGPMIME // Not supported yet 29 | ) 30 | 31 | const ( 32 | // ActionEncrypt will only encrypt the mail body but not sign the outcome 33 | ActionEncrypt Action = iota 34 | // ActionEncryptAndSign will encrypt the mail body and sign the the outcome accordingly 35 | ActionEncryptAndSign 36 | // ActionSign will only sign the mail body but not encrypt any data 37 | ActionSign 38 | ) 39 | 40 | var ( 41 | // ErrNoPrivKey should be returned if a private key is needed but not provided 42 | ErrNoPrivKey = errors.New("no private key provided") 43 | // ErrNoPubKey should be returned if a public key is needed but not provided 44 | ErrNoPubKey = errors.New("no public key provided") 45 | // ErrUnsupportedAction should be returned if a not supported action is set 46 | ErrUnsupportedAction = errors.New("unsupported action") 47 | ) 48 | 49 | // Config is the confiuration to use in Middleware creation 50 | type Config struct { 51 | // Action represents the encryption/signing action that the Middlware should perform 52 | Action Action 53 | // Logger represents a log that satisfies the log.Logger interface 54 | Logger *log.Logger 55 | // PrivKey represents the OpenPGP/GPG private key part used for signing the mail 56 | PrivKey string 57 | // PublicKey represents the OpenPGP/GPG public key used for encrypting the mail 58 | PublicKey string 59 | // Schema represents one of the supported PGP encryption schemes 60 | Scheme PGPScheme 61 | 62 | // passphrase is the passphrase for the private key 63 | passphrase string 64 | } 65 | 66 | // Option returns a function that can be used for grouping SignerConfig options 67 | type Option func(cfg *Config) 68 | 69 | // NewConfigFromPubKeyByteSlice returns a new Config from a given OpenPGP/GPG public 70 | // key byte slice. 71 | func NewConfigFromPubKeyByteSlice(p []byte, o ...Option) (*Config, error) { 72 | return NewConfig("", string(p), o...) 73 | } 74 | 75 | // NewConfigFromPrivKeyByteSlice returns a new Config from a given OpenPGP/GPG private 76 | // key byte slice. 77 | func NewConfigFromPrivKeyByteSlice(p []byte, o ...Option) (*Config, error) { 78 | return NewConfig(string(p), "", o...) 79 | } 80 | 81 | // NewConfigFromKeysByteSlices returns a new Config from a given OpenPGP/GPG public 82 | // and private keys byte slices. 83 | func NewConfigFromKeysByteSlices(pr, pu []byte, o ...Option) (*Config, error) { 84 | return NewConfig(string(pr), string(pu), o...) 85 | } 86 | 87 | // NewConfigFromPubKeyFile returns a new Config from a given OpenPGP/GPG public 88 | // key file. 89 | func NewConfigFromPubKeyFile(f string, o ...Option) (*Config, error) { 90 | p, err := os.ReadFile(f) 91 | if err != nil { 92 | return nil, err 93 | } 94 | return NewConfig("", string(p), o...) 95 | } 96 | 97 | // NewConfigFromPrivKeyFile returns a new Config from a given OpenPGP/GPG private 98 | // key file. 99 | func NewConfigFromPrivKeyFile(f string, o ...Option) (*Config, error) { 100 | p, err := os.ReadFile(f) 101 | if err != nil { 102 | return nil, err 103 | } 104 | return NewConfig(string(p), "", o...) 105 | } 106 | 107 | // NewConfigFromKeyFiles returns a new Config from a given OpenPGP/GPG private 108 | // and public key files. 109 | func NewConfigFromKeyFiles(pr, pu string, o ...Option) (*Config, error) { 110 | prd, err := os.ReadFile(pr) 111 | if err != nil { 112 | return nil, err 113 | } 114 | pud, err := os.ReadFile(pu) 115 | if err != nil { 116 | return nil, err 117 | } 118 | return NewConfig(string(prd), string(pud), o...) 119 | } 120 | 121 | // NewConfig returns a new Config struct. All values can be prefilled/overriden 122 | // using the With*() Option methods 123 | func NewConfig(pr, pu string, o ...Option) (*Config, error) { 124 | c := &Config{PrivKey: pr, PublicKey: pu} 125 | 126 | // Override defaults with optionally provided Option functions 127 | for _, co := range o { 128 | if co == nil { 129 | continue 130 | } 131 | co(c) 132 | } 133 | 134 | if c.PrivKey == "" && (c.Action == ActionSign || c.Action == ActionEncryptAndSign) { 135 | return c, fmt.Errorf("message signing requires a private key: %w", ErrNoPrivKey) 136 | } 137 | if c.PublicKey == "" && (c.Action == ActionEncrypt || c.Action == ActionEncryptAndSign) { 138 | return c, fmt.Errorf("message encryption requires a public key: %w", ErrNoPubKey) 139 | } 140 | 141 | // Create a slog.TextHandler logger if none was provided 142 | if c.Logger == nil { 143 | c.Logger = log.New(os.Stderr, "openpgp", log.LevelWarn) 144 | } 145 | 146 | return c, nil 147 | } 148 | 149 | // WithLogger sets a slog.Logger for the Config 150 | func WithLogger(l *log.Logger) Option { 151 | return func(c *Config) { 152 | c.Logger = l 153 | } 154 | } 155 | 156 | // WithScheme sets a PGPScheme for the Config 157 | func WithScheme(s PGPScheme) Option { 158 | return func(c *Config) { 159 | c.Scheme = s 160 | } 161 | } 162 | 163 | // WithAction sets a Action for the Config 164 | func WithAction(a Action) Option { 165 | return func(c *Config) { 166 | c.Action = a 167 | } 168 | } 169 | 170 | // WithPrivKeyPass sets a passphrase for the PrivKey in the Config 171 | func WithPrivKeyPass(p string) Option { 172 | return func(c *Config) { 173 | c.passphrase = p 174 | } 175 | } 176 | 177 | // String satisfies the fmt.Stringer interface for the PGPScheme type 178 | func (s PGPScheme) String() string { 179 | switch s { 180 | case SchemePGPInline: 181 | return "PGP/Inline" 182 | case SchemePGPMIME: 183 | return "PGP/MIME" 184 | default: 185 | return "unknown" 186 | } 187 | } 188 | 189 | // String satisfies the fmt.Stringer interface for the Action type 190 | func (a Action) String() string { 191 | switch a { 192 | case ActionEncrypt: 193 | return "Encrypt-only" 194 | case ActionEncryptAndSign: 195 | return "Encrypt/Sign" 196 | case ActionSign: 197 | return "Sign-only" 198 | default: 199 | return "unknown" 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /openpgp/config_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package openpgp 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "testing" 11 | 12 | "github.com/wneessen/go-mail-middleware/log" 13 | ) 14 | 15 | func TestNewConfig(t *testing.T) { 16 | mc, err := NewConfig(privKey, pubKey, nil) 17 | if err != nil { 18 | t.Errorf("failed to create new config: %s", err) 19 | } 20 | if mc.Scheme != SchemePGPInline { 21 | t.Errorf("NewConfig failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 22 | } 23 | if mc.Logger == nil { 24 | t.Errorf("NewConfig failed. Expected log logger but got nil") 25 | } 26 | if mc.PublicKey == "" { 27 | t.Errorf("NewConfig failed. Expected public key but got empty string") 28 | } 29 | if mc.PublicKey != pubKey { 30 | t.Errorf("NewConfig failed. Public key does not match") 31 | } 32 | if mc.PrivKey == "" { 33 | t.Errorf("NewConfig failed. Expected private key but got empty string") 34 | } 35 | if mc.PrivKey != privKey { 36 | t.Errorf("NewConfig failed. Private key does not match") 37 | } 38 | } 39 | 40 | func TestNewConfigFromPubKeyBytes(t *testing.T) { 41 | mc, err := NewConfigFromPubKeyByteSlice([]byte(pubKey)) 42 | if err != nil { 43 | t.Errorf("failed to create new config: %s", err) 44 | } 45 | if mc.Scheme != SchemePGPInline { 46 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 47 | } 48 | if mc.Logger == nil { 49 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected log logger but got nil") 50 | } 51 | if mc.PublicKey == "" { 52 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected public key but got empty string") 53 | } 54 | if mc.PublicKey != pubKey { 55 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Public key does not match") 56 | } 57 | } 58 | 59 | func TestNewConfigFromPrivKeyBytes(t *testing.T) { 60 | mc, err := NewConfigFromPrivKeyByteSlice([]byte(privKey), WithAction(ActionSign)) 61 | if err != nil { 62 | t.Errorf("failed to create new config: %s", err) 63 | } 64 | if mc.Scheme != SchemePGPInline { 65 | t.Errorf("NewConfigFromPrivKeyByteSlice failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 66 | } 67 | if mc.Logger == nil { 68 | t.Errorf("NewConfigFromPrivKeyByteSlice failed. Expected log logger but got nil") 69 | } 70 | if mc.PrivKey == "" { 71 | t.Errorf("NewConfigFromPrivKeyByteSlice failed. Expected public key but got empty string") 72 | } 73 | if mc.PrivKey != privKey { 74 | t.Errorf("NewConfigFromPrivKeyByteSlice failed. Private key does not match") 75 | } 76 | } 77 | 78 | func TestNewConfigFromKeysBytes(t *testing.T) { 79 | mc, err := NewConfigFromKeysByteSlices([]byte(privKey), []byte(pubKey)) 80 | if err != nil { 81 | t.Errorf("failed to create new config: %s", err) 82 | } 83 | if mc.Scheme != SchemePGPInline { 84 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 85 | } 86 | if mc.Logger == nil { 87 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected log logger but got nil") 88 | } 89 | if mc.PublicKey == "" { 90 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Expected public key but got empty string") 91 | } 92 | if mc.PublicKey != pubKey { 93 | t.Errorf("NewConfigFromPubKeyByteSlice failed. Public key does not match") 94 | } 95 | if mc.PrivKey == "" { 96 | t.Errorf("NewConfigFromKeysByteSlices failed. Expected private key but got empty string") 97 | } 98 | if mc.PrivKey != privKey { 99 | t.Errorf("NewConfigFromKeysByteSlices failed. Private key does not match") 100 | } 101 | } 102 | 103 | func TestNewConfigFromPubKeyFile(t *testing.T) { 104 | tmp, err := os.MkdirTemp(os.TempDir(), "go-mail-middleware-openpgp_") 105 | if err != nil { 106 | t.Errorf("failed to create temporary directory for key file") 107 | return 108 | } 109 | defer func() { _ = os.RemoveAll(tmp) }() 110 | file := fmt.Sprintf("%s/%s", tmp, "pubkey.asc") 111 | if err := os.WriteFile(file, []byte(pubKey), 0o700); err != nil { 112 | t.Errorf("failed to write public key to temporary file %q: %s", file, err) 113 | return 114 | } 115 | mc, err := NewConfigFromPubKeyFile(file) 116 | if err != nil { 117 | t.Errorf("failed to create new config: %s", err) 118 | } 119 | if mc.Scheme != SchemePGPInline { 120 | t.Errorf("NewConfigFromPubKeyFile failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 121 | } 122 | if mc.Logger == nil { 123 | t.Errorf("NewConfigFromPubKeyFile failed. Expected log logger but got nil") 124 | } 125 | if mc.PublicKey == "" { 126 | t.Errorf("NewConfigFromPubKeyFile failed. Expected public key but got empty string") 127 | } 128 | if mc.PublicKey != pubKey { 129 | t.Errorf("NewConfigFromPubKeyFile failed. Public key does not match") 130 | } 131 | } 132 | 133 | func TestNewConfigFromPrivKeyFile(t *testing.T) { 134 | tmp, err := os.MkdirTemp(os.TempDir(), "go-mail-middleware-openpgp_") 135 | if err != nil { 136 | t.Errorf("failed to create temporary directory for key file") 137 | return 138 | } 139 | defer func() { _ = os.RemoveAll(tmp) }() 140 | file := fmt.Sprintf("%s/%s", tmp, "privkey.asc") 141 | if err := os.WriteFile(file, []byte(privKey), 0o700); err != nil { 142 | t.Errorf("failed to write public key to temporary file %q: %s", file, err) 143 | return 144 | } 145 | mc, err := NewConfigFromPrivKeyFile(file, WithAction(ActionSign)) 146 | if err != nil { 147 | t.Errorf("failed to create new config: %s", err) 148 | } 149 | if mc.Scheme != SchemePGPInline { 150 | t.Errorf("NewConfigFromPrivKeyFile failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 151 | } 152 | if mc.Logger == nil { 153 | t.Errorf("NewConfigFromPrivKeyFile failed. Expected log logger but got nil") 154 | } 155 | if mc.PrivKey == "" { 156 | t.Errorf("NewConfigFromPrivKeyFile failed. Expected public key but got empty string") 157 | } 158 | if mc.PrivKey != privKey { 159 | t.Errorf("NewConfigFromPrivKeyFile failed. Private key does not match") 160 | } 161 | } 162 | 163 | func TestNewConfigFromKeysFiles(t *testing.T) { 164 | tmp, err := os.MkdirTemp(os.TempDir(), "go-mail-middleware-openpgp_") 165 | if err != nil { 166 | t.Errorf("failed to create temporary directory for key file") 167 | return 168 | } 169 | defer func() { _ = os.RemoveAll(tmp) }() 170 | pubfile := fmt.Sprintf("%s/%s", tmp, "pubkey.asc") 171 | if err := os.WriteFile(pubfile, []byte(pubKey), 0o700); err != nil { 172 | t.Errorf("failed to write public key to temporary file %q: %s", pubfile, err) 173 | return 174 | } 175 | privfile := fmt.Sprintf("%s/%s", tmp, "privkey.asc") 176 | if err := os.WriteFile(privfile, []byte(privKey), 0o700); err != nil { 177 | t.Errorf("failed to write private key to temporary file %q: %s", privfile, err) 178 | return 179 | } 180 | mc, err := NewConfigFromKeyFiles(privfile, pubfile) 181 | if err != nil { 182 | t.Errorf("failed to create new config: %s", err) 183 | } 184 | if mc.Scheme != SchemePGPInline { 185 | t.Errorf("NewConfigFromKeyFiles failed. Expected Scheme %d, got: %d", SchemePGPInline, mc.Scheme) 186 | } 187 | if mc.Logger == nil { 188 | t.Errorf("NewConfigFromKeyFiles failed. Expected log logger but got nil") 189 | } 190 | if mc.PublicKey == "" { 191 | t.Errorf("NewConfigFromKeyFiles failed. Expected public key but got empty string") 192 | } 193 | if mc.PublicKey != pubKey { 194 | t.Errorf("NewConfigFromKeyFiles failed. Public key does not match") 195 | } 196 | if mc.PrivKey == "" { 197 | t.Errorf("NewConfigFromKeyFiles failed. Expected private key but got empty string") 198 | } 199 | if mc.PrivKey != privKey { 200 | t.Errorf("NewConfigFromKeyFiles failed. Private key does not match") 201 | } 202 | } 203 | 204 | func TestNewConfigFromFiles_failed(t *testing.T) { 205 | const f = "/file/does/not/exist/at/all.pgp" 206 | tmp, err := os.MkdirTemp(os.TempDir(), "go-mail-middleware-openpgp_") 207 | if err != nil { 208 | t.Errorf("failed to create temporary directory for key file") 209 | return 210 | } 211 | defer func() { _ = os.RemoveAll(tmp) }() 212 | ex := fmt.Sprintf("%s/%s", tmp, "exists.asc") 213 | if err := os.WriteFile(ex, []byte("file exists"), 0o700); err != nil { 214 | t.Errorf("failed to write to temporary file %q: %s", ex, err) 215 | return 216 | } 217 | _, err = NewConfigFromPubKeyFile(f) 218 | if err == nil { 219 | t.Errorf("reading from non existing file(s) should have failed, but didn't") 220 | } 221 | _, err = NewConfigFromPrivKeyFile(f) 222 | if err == nil { 223 | t.Errorf("reading from non existing file(s) should have failed, but didn't") 224 | } 225 | _, err = NewConfigFromKeyFiles(f, f) 226 | if err == nil { 227 | t.Errorf("reading from non existing file(s) should have failed, but didn't") 228 | } 229 | _, err = NewConfigFromKeyFiles(ex, f) 230 | if err == nil { 231 | t.Errorf("reading from non existing file(s) should have failed, but didn't") 232 | } 233 | _, err = NewConfigFromKeyFiles(f, ex) 234 | if err == nil { 235 | t.Errorf("reading from non existing file(s) should have failed, but didn't") 236 | } 237 | } 238 | 239 | func TestNewConfig_WithLogger(t *testing.T) { 240 | l := log.New(os.Stderr, "[openpgp-custom]", log.LevelWarn) 241 | mc, err := NewConfig(privKey, pubKey, WithLogger(l)) 242 | if err != nil { 243 | t.Errorf("failed to create new config: %s", err) 244 | } 245 | if mc.Logger == nil { 246 | t.Errorf("NewConfig_WithLogger failed. Expected slog logger but got empty field") 247 | } 248 | } 249 | 250 | func TestNewConfig_WithPrivKeyPass(t *testing.T) { 251 | p := "sup3rS3cret!" 252 | mc, err := NewConfig(privKey, pubKey, WithPrivKeyPass(p)) 253 | if err != nil { 254 | t.Errorf("failed to create new config: %s", err) 255 | } 256 | if mc.passphrase == "" { 257 | t.Errorf("NewConfig_WithPrivKeyPass failed. Expected value but got empty string") 258 | } 259 | if mc.passphrase != p { 260 | t.Errorf("NewConfig_WithPrivKeyPass failed. Expected: %s, got: %s", p, mc.passphrase) 261 | } 262 | } 263 | 264 | func TestNewConfig_WithScheme(t *testing.T) { 265 | tests := []struct { 266 | n string 267 | s PGPScheme 268 | }{ 269 | {"PGP/Inline", SchemePGPInline}, 270 | {"PGP/MIME", SchemePGPMIME}, 271 | } 272 | 273 | for _, tt := range tests { 274 | t.Run(tt.n, func(t *testing.T) { 275 | mc, err := NewConfig(privKey, pubKey, WithScheme(tt.s)) 276 | if err != nil { 277 | t.Errorf("NewConfig_WithScheme %q failed: %s", tt.s, err) 278 | } 279 | if mc.Scheme != tt.s { 280 | t.Errorf("NewConfig_WithScheme failed. Expected %s, got %s", tt.s, mc.Scheme) 281 | } 282 | if mc.Scheme.String() == "unknown" { 283 | t.Errorf("NewConfig_WithScheme failed. Received unknown type") 284 | } 285 | }) 286 | } 287 | } 288 | 289 | func TestNewConfig_WithAction(t *testing.T) { 290 | tests := []struct { 291 | n string 292 | a Action 293 | }{ 294 | {"Encrypt-only", ActionEncrypt}, 295 | {"Encrypt/Sign", ActionEncryptAndSign}, 296 | {"Sign-only", ActionSign}, 297 | } 298 | 299 | for _, tt := range tests { 300 | t.Run(tt.n, func(t *testing.T) { 301 | mc, err := NewConfig(privKey, pubKey, WithAction(tt.a)) 302 | if err != nil { 303 | t.Errorf("NewConfig_WithAction %q failed: %s", tt.a, err) 304 | } 305 | if mc.Action != tt.a { 306 | t.Errorf("NewConfig_WithAction failed. Expected %s, got %s", tt.a, mc.Action) 307 | } 308 | if mc.Action.String() == "unknown" { 309 | t.Errorf("NewConfig_WithAction failed. Received unknown type") 310 | } 311 | }) 312 | } 313 | } 314 | 315 | func TestNewConfig_WithAction_fails(t *testing.T) { 316 | tests := []struct { 317 | n string 318 | a Action 319 | pr string 320 | pu string 321 | f bool 322 | }{ 323 | {"Encrypt-only, PubKey, PrivKey", ActionEncrypt, privKey, pubKey, false}, 324 | {"Encrypt-only, PubKey, NoPrivKey", ActionEncrypt, "", pubKey, false}, 325 | {"Encrypt-only, NoPubKey, PrivKey", ActionEncrypt, privKey, "", true}, 326 | {"Encrypt-only, NoPubKey, NoPrivKey", ActionEncrypt, "", "", true}, 327 | {"Encrypt/Sign, PubKey, PrivKey", ActionEncryptAndSign, privKey, pubKey, false}, 328 | {"Encrypt/Sign, PubKey, NoPrivKey", ActionEncryptAndSign, "", pubKey, true}, 329 | {"Encrypt/Sign, NoPubKey, PrivKey", ActionEncryptAndSign, privKey, "", true}, 330 | {"Encrypt/Sign, NoPubKey, NoPrivKey", ActionEncryptAndSign, "", "", true}, 331 | {"Sign-only, PubKey, PrivKey", ActionSign, privKey, pubKey, false}, 332 | {"Sign-only, PubKey, NoPrivKey", ActionSign, "", pubKey, true}, 333 | {"Sign-only, NoPubKey, PrivKey", ActionSign, privKey, "", false}, 334 | {"Sign-only, NoPubKey, NoPrivKey", ActionSign, "", "", true}, 335 | } 336 | 337 | for _, tt := range tests { 338 | t.Run(tt.n, func(t *testing.T) { 339 | _, err := NewConfig(tt.pr, tt.pu, WithAction(tt.a)) 340 | if err != nil && !tt.f { 341 | t.Errorf("NewConfig_WithAction %q failed: %s", tt.a, err) 342 | } 343 | }) 344 | } 345 | } 346 | 347 | func TestPGPSchemeString(t *testing.T) { 348 | tests := []struct { 349 | name string 350 | s PGPScheme 351 | want string 352 | }{ 353 | {"inline", SchemePGPInline, "PGP/Inline"}, 354 | {"mime", SchemePGPMIME, "PGP/MIME"}, 355 | {"unknown", PGPScheme(3), "unknown"}, 356 | } 357 | for _, tt := range tests { 358 | t.Run(tt.name, func(t *testing.T) { 359 | if got := tt.s.String(); got != tt.want { 360 | t.Errorf("PGPScheme.String() = %v, want %v", got, tt.want) 361 | } 362 | }) 363 | } 364 | } 365 | 366 | func TestActionString(t *testing.T) { 367 | tests := []struct { 368 | name string 369 | a Action 370 | want string 371 | }{ 372 | {"encrypt", ActionEncrypt, "Encrypt-only"}, 373 | {"encrypt-sign", ActionEncryptAndSign, "Encrypt/Sign"}, 374 | {"sign", ActionSign, "Sign-only"}, 375 | {"unknown", Action(3), "unknown"}, 376 | } 377 | for _, tt := range tests { 378 | t.Run(tt.name, func(t *testing.T) { 379 | if got := tt.a.String(); got != tt.want { 380 | t.Errorf("Action.String() = %v, want %v", got, tt.want) 381 | } 382 | }) 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /openpgp/helper.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package openpgp 6 | 7 | import ( 8 | "bytes" 9 | 10 | "github.com/ProtonMail/gopenpgp/v2/armor" 11 | "github.com/ProtonMail/gopenpgp/v2/constants" 12 | "github.com/ProtonMail/gopenpgp/v2/helper" 13 | "github.com/wneessen/go-mail" 14 | ) 15 | 16 | const ( 17 | // armorComment is the comment string used for the OpenPGP Armor 18 | armorComment = "https://go-mail.dev (OpenPGP based on: https://gopenpgp.org)" 19 | // armorVeersion is the version string used for the OpenPGP Armor 20 | armorVersion = "go-mail-middlware " + Version 21 | ) 22 | 23 | // pgpInline takes the given mail.Msg and encrypts/signs the body parts 24 | // and attachments and replaces them with an PGP encrypted data blob embedded 25 | // into the mail body following the PGP/Inline scheme 26 | func (m *Middleware) pgpInline(msg *mail.Msg) *mail.Msg { 27 | pp := msg.GetParts() 28 | for _, part := range pp { 29 | c, err := part.GetContent() 30 | if err != nil { 31 | m.config.Logger.Errorf("failed to get part content: %s", err) 32 | continue 33 | } 34 | switch part.GetContentType() { 35 | case mail.TypeTextPlain: 36 | s, err := m.processPlain(string(c)) 37 | if err != nil { 38 | m.config.Logger.Errorf("failed to encrypt message part: %s", err) 39 | continue 40 | } 41 | part.SetEncoding(mail.EncodingB64) 42 | part.SetContent(s) 43 | default: 44 | m.config.Logger.Warnf("unsupported type %q. removing message part", string(part.GetContentType())) 45 | part.Delete() 46 | } 47 | } 48 | 49 | buf := bytes.Buffer{} 50 | ef := msg.GetEmbeds() 51 | msg.SetEmbeds(nil) 52 | for _, f := range ef { 53 | _, err := f.Writer(&buf) 54 | if err != nil { 55 | m.config.Logger.Errorf("failed to write attachment to memory: %s", err) 56 | continue 57 | } 58 | b, err := m.processBinary(buf.Bytes()) 59 | if err != nil { 60 | m.config.Logger.Errorf("failed to encrypt attachment: %s", err) 61 | continue 62 | } 63 | if err := msg.EmbedReader(f.Name, bytes.NewReader([]byte(b))); err != nil { 64 | m.config.Logger.Errorf("failed to embed reader: %s", err) 65 | continue 66 | } 67 | buf.Reset() 68 | } 69 | af := msg.GetAttachments() 70 | msg.SetAttachments(nil) 71 | for _, f := range af { 72 | _, err := f.Writer(&buf) 73 | if err != nil { 74 | m.config.Logger.Errorf("failed to write attachment to memory: %s", err) 75 | continue 76 | } 77 | b, err := m.processBinary(buf.Bytes()) 78 | if err != nil { 79 | m.config.Logger.Errorf("failed to encrypt attachment: %s", err) 80 | continue 81 | } 82 | if err := msg.AttachReader(f.Name, bytes.NewReader([]byte(b))); err != nil { 83 | m.config.Logger.Errorf("failed to attach reader: %s", err) 84 | continue 85 | } 86 | buf.Reset() 87 | } 88 | 89 | return msg 90 | } 91 | 92 | // processBinary is a helper function that processes the given data based on the 93 | // configured Action 94 | func (m *Middleware) processBinary(d []byte) (string, error) { 95 | var ct string 96 | var err error 97 | switch m.config.Action { 98 | case ActionEncrypt: 99 | ct, err = helper.EncryptBinaryMessageArmored(m.config.PublicKey, d) 100 | case ActionEncryptAndSign: 101 | // TODO: Waiting for reply to https://github.com/ProtonMail/gopenpgp/issues/213 102 | ct, err = helper.EncryptSignMessageArmored(m.config.PublicKey, m.config.PrivKey, 103 | []byte(m.config.passphrase), string(d)) 104 | case ActionSign: 105 | // TODO: Does this work with binary? 106 | return helper.SignCleartextMessageArmored(m.config.PrivKey, []byte(m.config.passphrase), string(d)) 107 | default: 108 | return "", ErrUnsupportedAction 109 | } 110 | if err != nil { 111 | return ct, err 112 | } 113 | return m.reArmorMessage(ct) 114 | } 115 | 116 | // processPlain is a helper function that processes the given data based on the 117 | // configured Action 118 | func (m *Middleware) processPlain(d string) (string, error) { 119 | var ct string 120 | var err error 121 | switch m.config.Action { 122 | case ActionEncrypt: 123 | ct, err = helper.EncryptMessageArmored(m.config.PublicKey, d) 124 | case ActionEncryptAndSign: 125 | ct, err = helper.EncryptSignMessageArmored(m.config.PublicKey, m.config.PrivKey, 126 | []byte(m.config.passphrase), d) 127 | case ActionSign: 128 | return helper.SignCleartextMessageArmored(m.config.PrivKey, []byte(m.config.passphrase), d) 129 | default: 130 | return "", ErrUnsupportedAction 131 | } 132 | if err != nil { 133 | return ct, err 134 | } 135 | return m.reArmorMessage(ct) 136 | } 137 | 138 | // reArmorMessage unarmors the PGP message and re-armors it with the package specific 139 | // comment and version strings 140 | func (m *Middleware) reArmorMessage(d string) (string, error) { 141 | ua, err := armor.Unarmor(d) 142 | if err != nil { 143 | return d, err 144 | } 145 | return armor.ArmorWithTypeAndCustomHeaders(ua, constants.PGPMessageHeader, armorVersion, armorComment) 146 | } 147 | -------------------------------------------------------------------------------- /openpgp/helper_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package openpgp 6 | 7 | import ( 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/ProtonMail/gopenpgp/v2/helper" 13 | ) 14 | 15 | func TestMiddleware_reArmorMessage(t *testing.T) { 16 | ts := "This is the test message" 17 | mc, err := NewConfig(privKey, pubKey, WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS"))) 18 | if err != nil { 19 | t.Errorf("failed to create new config: %s", err) 20 | } 21 | mw := NewMiddleware(mc) 22 | ct, err := helper.EncryptMessageArmored(mw.config.PublicKey, ts) 23 | if err != nil { 24 | t.Errorf("failed to encrypt message: %s", err) 25 | } 26 | ra, err := mw.reArmorMessage(ct) 27 | if err != nil { 28 | t.Errorf("reArmorMessage failed: %s", err) 29 | } 30 | if !strings.Contains(ra, armorComment) || !strings.Contains(ra, armorVersion) { 31 | t.Errorf("reArmorMessage failed. Expected version/comment but didn't find it") 32 | } 33 | pt, err := helper.DecryptMessageArmored(mw.config.PrivKey, []byte(mw.config.passphrase), ra) 34 | if err != nil { 35 | t.Errorf("reArmorMessage failed. Decryption of re-armored message failed: %s", err) 36 | } 37 | if pt != ts { 38 | t.Errorf("reArmorMessage failed. Expected: %q, got: %q", ts, pt) 39 | } 40 | } 41 | 42 | func TestMiddleware_reArmorMessage_failed(t *testing.T) { 43 | ts := "This is the test message" 44 | mc, err := NewConfig(privKey, pubKey) 45 | if err != nil { 46 | t.Errorf("failed to create new config: %s", err) 47 | } 48 | mw := NewMiddleware(mc) 49 | _, err = mw.reArmorMessage(ts) 50 | if err == nil { 51 | t.Errorf("reArmorMessage with no armored message was supposed to fail, but didn't") 52 | } 53 | } 54 | 55 | func TestMiddleware_processPlain(t *testing.T) { 56 | tests := []struct { 57 | n string 58 | a Action 59 | }{ 60 | {"Encrypt-only", ActionEncrypt}, 61 | {"Encrypt/Sign", ActionEncryptAndSign}, 62 | {"Sign-only", ActionSign}, 63 | } 64 | ts := "This is the test message" 65 | 66 | for _, tt := range tests { 67 | t.Run(tt.n, func(t *testing.T) { 68 | mc, err := NewConfig(privKey, pubKey, 69 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 70 | WithAction(tt.a), 71 | ) 72 | if err != nil { 73 | t.Errorf("failed to create new config: %s", err) 74 | } 75 | mw := NewMiddleware(mc) 76 | ct, err := mw.processPlain(ts) 77 | if err != nil { 78 | t.Errorf("processPlain failed: %s", err) 79 | } 80 | if tt.a == ActionEncrypt { 81 | pt, err := helper.DecryptMessageArmored(mw.config.PrivKey, []byte(mw.config.passphrase), ct) 82 | if err != nil { 83 | t.Errorf("processPlain failed. Decryption of message failed: %s", err) 84 | } 85 | if pt != ts { 86 | t.Errorf("processPlain failed. Expected: %q, got: %q", ts, pt) 87 | } 88 | } 89 | if tt.a == ActionEncryptAndSign { 90 | pt, err := helper.DecryptVerifyMessageArmored(mw.config.PublicKey, mw.config.PrivKey, 91 | []byte(mw.config.passphrase), ct) 92 | if err != nil { 93 | t.Errorf("processPlain failed. Decryption of message failed: %s", err) 94 | } 95 | if pt != ts { 96 | t.Errorf("processPlain failed. Expected: %q, got: %q", ts, pt) 97 | } 98 | } 99 | if tt.a == ActionSign { 100 | pt, err := helper.VerifyCleartextMessageArmored(mw.config.PublicKey, ct, 0) 101 | if err != nil { 102 | t.Errorf("processPlain failed. Verification of message failed: %s", err) 103 | } 104 | if pt != ts { 105 | t.Errorf("processPlain failed. Expected: %q, got: %q", ts, pt) 106 | } 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestMiddleware_processPlain_fail(t *testing.T) { 113 | ts := "This is the test message" 114 | mc, err := NewConfig(privKey, pubKey, 115 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 116 | WithAction(999), 117 | ) 118 | if err != nil { 119 | t.Errorf("failed to create new config: %s", err) 120 | } 121 | mw := NewMiddleware(mc) 122 | _, err = mw.processPlain(ts) 123 | if err == nil { 124 | t.Errorf("processPlain with unknown action was supposed to fail, but didn't") 125 | } 126 | mc, err = NewConfig(privKey, pubKey, 127 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 128 | WithAction(ActionEncrypt), 129 | ) 130 | if err != nil { 131 | t.Errorf("failed to create new config: %s", err) 132 | } 133 | mw = NewMiddleware(mc) 134 | mw.config.PublicKey = "" 135 | _, err = mw.processPlain(ts) 136 | if err == nil { 137 | t.Errorf("processPlain with empty pubkey was supposed to fail, but didn't") 138 | } 139 | } 140 | 141 | func TestMiddleware_processBinary(t *testing.T) { 142 | tests := []struct { 143 | n string 144 | a Action 145 | }{ 146 | {"Encrypt-only", ActionEncrypt}, 147 | {"Encrypt/Sign", ActionEncryptAndSign}, 148 | {"Sign-only", ActionSign}, 149 | } 150 | ts := []byte("This is the test message") 151 | 152 | for _, tt := range tests { 153 | t.Run(tt.n, func(t *testing.T) { 154 | mc, err := NewConfig(privKey, pubKey, 155 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 156 | WithAction(tt.a), 157 | ) 158 | if err != nil { 159 | t.Errorf("failed to create new config: %s", err) 160 | } 161 | mw := NewMiddleware(mc) 162 | ct, err := mw.processBinary(ts) 163 | if err != nil { 164 | t.Errorf("processBinary failed: %s", err) 165 | } 166 | if tt.a == ActionEncrypt { 167 | pt, err := helper.DecryptMessageArmored(mw.config.PrivKey, []byte(mw.config.passphrase), ct) 168 | if err != nil { 169 | t.Errorf("processBinary failed. Decryption of message failed: %s", err) 170 | } 171 | if pt != string(ts) { 172 | t.Errorf("processBinary failed. Expected: %q, got: %q", ts, pt) 173 | } 174 | } 175 | if tt.a == ActionEncryptAndSign { 176 | pt, err := helper.DecryptVerifyMessageArmored(mw.config.PublicKey, mw.config.PrivKey, 177 | []byte(mw.config.passphrase), ct) 178 | if err != nil { 179 | t.Errorf("processBinary failed. Decryption of message failed: %s", err) 180 | } 181 | if pt != string(ts) { 182 | t.Errorf("processBinary failed. Expected: %q, got: %q", ts, pt) 183 | } 184 | } 185 | if tt.a == ActionSign { 186 | pt, err := helper.VerifyCleartextMessageArmored(mw.config.PublicKey, ct, 0) 187 | if err != nil { 188 | t.Errorf("processBinary failed. Verification of message failed: %s", err) 189 | } 190 | if pt != string(ts) { 191 | t.Errorf("processBinary failed. Expected: %q, got: %q", ts, pt) 192 | } 193 | } 194 | }) 195 | } 196 | } 197 | 198 | func TestMiddleware_processBinary_fail(t *testing.T) { 199 | ts := []byte("This is the test message") 200 | mc, err := NewConfig(privKey, pubKey, 201 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 202 | WithAction(999), 203 | ) 204 | if err != nil { 205 | t.Errorf("failed to create new config: %s", err) 206 | } 207 | mw := NewMiddleware(mc) 208 | _, err = mw.processBinary(ts) 209 | if err == nil { 210 | t.Errorf("processBinary with unknown action was supposed to fail, but didn't") 211 | } 212 | mc, err = NewConfig(privKey, pubKey, 213 | WithPrivKeyPass(os.Getenv("PRIV_KEY_PASS")), 214 | WithAction(ActionEncrypt), 215 | ) 216 | if err != nil { 217 | t.Errorf("failed to create new config: %s", err) 218 | } 219 | mw = NewMiddleware(mc) 220 | mw.config.PublicKey = "" 221 | _, err = mw.processBinary(ts) 222 | if err == nil { 223 | t.Errorf("processBinary with empty pubkey was supposed to fail, but didn't") 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /openpgp/openpgp.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Dhia Gharsallaoui 2 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 3 | // 4 | // SPDX-License-Identifier: MIT 5 | 6 | // Package openpgp implements a go-mail middleware to encrypt mails via OpenPGP 7 | package openpgp 8 | 9 | import ( 10 | "github.com/wneessen/go-mail" 11 | ) 12 | 13 | const ( 14 | // Type is the type of Middleware 15 | Type mail.MiddlewareType = "openpgp" 16 | // Version is the version number of the Middleware 17 | Version = "0.0.1" 18 | ) 19 | 20 | // Middleware is the middleware struct for the openpgp middleware 21 | type Middleware struct { 22 | config *Config 23 | } 24 | 25 | // NewMiddleware returns a new Middleware from a given Config. 26 | // The returned Middleware satisfies the mail.Middleware interface 27 | func NewMiddleware(c *Config) *Middleware { 28 | mw := &Middleware{ 29 | config: c, 30 | } 31 | return mw 32 | } 33 | 34 | // Handle is the handler method that satisfies the mail.Middleware interface 35 | func (m *Middleware) Handle(msg *mail.Msg) *mail.Msg { 36 | switch m.config.Scheme { 37 | case SchemePGPInline: 38 | return m.pgpInline(msg) 39 | default: 40 | m.config.Logger.Errorf("unsupported scheme %q. sending mail unencrypted", m.config.Scheme) 41 | } 42 | return msg 43 | } 44 | 45 | // Type returns the MiddlewareType for this Middleware 46 | func (m *Middleware) Type() mail.MiddlewareType { 47 | return Type 48 | } 49 | -------------------------------------------------------------------------------- /openpgp/openpgp_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 The go-mail Authors 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package openpgp 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "encoding/base64" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/wneessen/go-mail" 15 | ) 16 | 17 | // pubkey is a dedicated OpenPGP key for testing this go-middleware. This key is 18 | // not used in any actual environment. Please don't use it to send any encrypted 19 | // mails 20 | const pubKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- 21 | 22 | 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 69 | avSf 70 | =JhVL 71 | -----END PGP PUBLIC KEY BLOCK-----` 72 | 73 | // privkey is a dedicated OpenPGP key for testing this go-middleware. This key is 74 | // not used in any actual environment. Please don't use it to send any encrypted 75 | // mails 76 | const privKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- 77 | 78 | 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 181 | =JfM9 182 | -----END PGP PRIVATE KEY BLOCK-----` 183 | 184 | func TestNewMiddleware(t *testing.T) { 185 | mc, err := NewConfig(privKey, pubKey) 186 | if err != nil { 187 | t.Errorf("failed to create new config: %s", err) 188 | } 189 | mw := NewMiddleware(mc) 190 | if mw.config == nil { 191 | t.Errorf("NewMiddleware failed. Expected config but got empty field") 192 | } 193 | } 194 | 195 | func TestMiddleware_HandlePGPInline(t *testing.T) { 196 | mc, err := NewConfig(privKey, pubKey, WithScheme(SchemePGPInline)) 197 | if err != nil { 198 | t.Errorf("failed to create new config: %s", err) 199 | } 200 | mw := NewMiddleware(mc) 201 | 202 | m := mail.NewMsg(mail.WithMiddleware(mw)) 203 | m.Subject("This is a subject") 204 | m.SetDate() 205 | m.SetBodyString(mail.TypeTextPlain, "This is the mail body") 206 | buf := bytes.Buffer{} 207 | _, err = m.WriteTo(&buf) 208 | if err != nil { 209 | t.Errorf("failed writing message to memory: %s", err) 210 | } 211 | br := bufio.NewScanner(&buf) 212 | fb := false 213 | body := "" 214 | for br.Scan() { 215 | l := br.Text() 216 | if l == "" { 217 | fb = true 218 | } 219 | if fb { 220 | body += l + "\n" 221 | } 222 | } 223 | bb, err := base64.StdEncoding.DecodeString(body) 224 | if err != nil { 225 | t.Errorf("failed to base64 decode message body: %s", err) 226 | } 227 | if !strings.Contains(string(bb), `-----BEGIN PGP MESSAGE-----`) || 228 | !strings.Contains(string(bb), `-----END PGP MESSAGE-----`) { 229 | t.Errorf("mail encryption failed. Unable to find PGP notation in mail body") 230 | } 231 | } 232 | 233 | func TestMiddleware_HandlePGPMIME(t *testing.T) { 234 | t.Skip("PGP/MIME not supported yet") 235 | mc, err := NewConfig(privKey, pubKey, WithScheme(SchemePGPMIME)) 236 | if err != nil { 237 | t.Errorf("failed to create new config: %s", err) 238 | } 239 | mw := NewMiddleware(mc) 240 | 241 | m := mail.NewMsg(mail.WithMiddleware(mw)) 242 | m.Subject("This is a subject") 243 | m.SetDate() 244 | m.SetBodyString(mail.TypeTextPlain, "This is the mail body") 245 | buf := bytes.Buffer{} 246 | _, err = m.WriteTo(&buf) 247 | if err != nil { 248 | t.Errorf("failed writing message to memory: %s", err) 249 | } 250 | br := bufio.NewScanner(&buf) 251 | fb := false 252 | body := "" 253 | for br.Scan() { 254 | l := br.Text() 255 | if l == "" { 256 | fb = true 257 | } 258 | if fb { 259 | body += l + "\n" 260 | } 261 | } 262 | bb, err := base64.StdEncoding.DecodeString(body) 263 | if err != nil { 264 | t.Errorf("failed to base64 decode message body: %s", err) 265 | } 266 | if !strings.Contains(string(bb), `-----BEGIN PGP MESSAGE-----`) || 267 | !strings.Contains(string(bb), `-----END PGP MESSAGE-----`) { 268 | t.Errorf("mail encryption failed. Unable to find PGP notation in mail body") 269 | } 270 | } 271 | 272 | func TestMiddleware_HandleUnknown(t *testing.T) { 273 | mc, err := NewConfig(privKey, pubKey, WithScheme(999)) 274 | if err != nil { 275 | t.Errorf("failed to create new config: %s", err) 276 | } 277 | mw := NewMiddleware(mc) 278 | 279 | m := mail.NewMsg(mail.WithMiddleware(mw)) 280 | m.Subject("This is a subject") 281 | m.SetDate() 282 | m.SetBodyString(mail.TypeTextPlain, "This is the mail body") 283 | buf := bytes.Buffer{} 284 | _, err = m.WriteTo(&buf) 285 | if err != nil { 286 | t.Errorf("failed writing message to memory: %s", err) 287 | } 288 | if len(m.GetParts()) != 1 { 289 | t.Errorf("Handle with unknown scheme failed. Mails parts seems modified") 290 | return 291 | } 292 | pc, err := m.GetParts()[0].GetContent() 293 | if err != nil { 294 | t.Errorf("failed to get message body part content") 295 | } 296 | if string(pc) != "This is the mail body" { 297 | t.Errorf("Handle with unknown scheme failed. Mails parts seems modified") 298 | } 299 | } 300 | 301 | func TestMiddleware_Type(t *testing.T) { 302 | mc, err := NewConfig(privKey, pubKey, WithScheme(999)) 303 | if err != nil { 304 | t.Errorf("failed to create new config: %s", err) 305 | } 306 | mw := NewMiddleware(mc) 307 | if mw.Type() != Type { 308 | t.Errorf("Type() failed. Expected: %s, got: %s", Type, mw.Type()) 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Winni Neessen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | sonar.projectKey=go-mail-middleware 6 | sonar.go.coverage.reportPaths=cov.out 7 | sonar.externalIssuesReportPaths=report.json 8 | -------------------------------------------------------------------------------- /subject_capitalize/README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Capitalize your subject based on a given language 8 | 9 | This is a simple middlware that makes use of the powerful [golang.org/x/text/cases](https://golang.org/x/text/cases) 10 | library. It will read the currently set subject of the `mail.Msg` and use the `cases` library to capitalize the 11 | subject based on the given language. 12 | 13 | ### Example 14 | ```go 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "github.com/wneessen/go-mail" 20 | "github.com/wneessen/go-mail-middleware/subject_capitalize" 21 | "golang.org/x/text/language" 22 | "os" 23 | ) 24 | 25 | func main() { 26 | m := mail.NewMsg(mail.WithMiddleware(subcap.New(language.English))) 27 | m.Subject("this is a test message") 28 | if err := m.WriteToFile("testmail.eml"); err != nil { 29 | fmt.Printf("failed to write mail message to file: %s\n", err) 30 | os.Exit(1) 31 | } 32 | } 33 | ``` -------------------------------------------------------------------------------- /subject_capitalize/subject_capitalize.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package subcap 6 | 7 | import ( 8 | "github.com/wneessen/go-mail" 9 | "golang.org/x/text/cases" 10 | "golang.org/x/text/language" 11 | ) 12 | 13 | // Middleware is the middleware struct for the capitalization middleware 14 | type Middleware struct { 15 | l language.Tag 16 | } 17 | 18 | const Type mail.MiddlewareType = "subcap" 19 | 20 | // New returns a new Middleware and can be used with the mail.WithMiddleware method. It takes a 21 | // language.Tag as input 22 | func New(l language.Tag) *Middleware { 23 | return &Middleware{l: l} 24 | } 25 | 26 | // Handle is the handler method that satisfies the mail.Middleware interface 27 | func (c Middleware) Handle(m *mail.Msg) *mail.Msg { 28 | cs := m.GetGenHeader(mail.HeaderSubject) 29 | if len(cs) <= 0 { 30 | return m 31 | } 32 | cp := cases.Title(c.l) 33 | m.Subject(cp.String(cs[0])) 34 | return m 35 | } 36 | 37 | // Type returns the MiddlewareType for this Middleware 38 | func (c Middleware) Type() mail.MiddlewareType { 39 | return Type 40 | } 41 | -------------------------------------------------------------------------------- /subject_capitalize/subject_capitalize_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Winni Neessen 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package subcap 6 | 7 | import ( 8 | "bytes" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/wneessen/go-mail" 13 | "golang.org/x/text/language" 14 | ) 15 | 16 | func TestNew(t *testing.T) { 17 | mw := New(language.English) 18 | if mw.l.String() != "en" { 19 | t.Errorf("New() failed. Expected language: %q, got: %q", "en", mw.l.String()) 20 | } 21 | } 22 | 23 | func TestMiddleware_Handle(t *testing.T) { 24 | m := mail.NewMsg(mail.WithMiddleware(New(language.English))) 25 | m.Subject("this is a test") 26 | buf := bytes.Buffer{} 27 | if _, err := m.WriteTo(&buf); err != nil { 28 | t.Errorf("failed to write mail message to buffer: %s", err) 29 | } 30 | if !strings.Contains(buf.String(), "This Is A Test") { 31 | t.Errorf("middleware failed. Expected: %q in subject, got: %q", "This Is A Test", buf.String()) 32 | } 33 | } 34 | 35 | func TestMiddleware_HandleEmpty(t *testing.T) { 36 | m := mail.NewMsg(mail.WithMiddleware(New(language.English))) 37 | buf := bytes.Buffer{} 38 | if _, err := m.WriteTo(&buf); err != nil { 39 | t.Errorf("failed to write mail message to buffer: %s", err) 40 | } 41 | } 42 | 43 | func TestMiddleware_Type(t *testing.T) { 44 | mw := New(language.English) 45 | if mw.Type() != Type { 46 | t.Errorf("failed to call Type(). Expected: %s, got: %s", Type, mw.Type()) 47 | } 48 | } 49 | --------------------------------------------------------------------------------