├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── workflow.yml ├── .gitignore ├── LICENSE ├── README.md ├── cache ├── messagecache.go └── messagecache_test.go ├── cmd ├── relay │ ├── go.mod │ ├── go.sum │ └── main.go ├── sendmail │ └── main.go └── signmail │ └── main.go ├── config.json ├── go.mod ├── go.sum └── lib ├── config.go ├── log.go ├── message.go ├── message_test.go ├── net.go ├── testdata └── test.eml └── transform.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '37 11 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '>=1.20.0' 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /signmail 2 | /sendmail 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Will Scott 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GoSendmail 2 | === 3 | 4 | A subset of sendmail functionality for modern self hosting. 5 | 6 | Motivation 7 | --- 8 | 9 | Traditional models of email security are designed around a 10 | trusted mail server, and semi-trusted user agents. The mail server should 11 | continue to perform reasonably even if an end-user machine is compromised. 12 | For single-user domains, realistic threat models are more likely to 13 | involve compromise of the publicly connected server, motivating a design 14 | minimizing trust in the front server. 15 | 16 | GoSendmail is an originating mail transfer agent for this threat model. 17 | 18 | It is designed as a counterpoint to 19 | [maildiranasaurus](https://github.com/flashmob/maildiranasaurus), which 20 | receives email on a semi-trusted server. 21 | 22 | Features 23 | --- 24 | 25 | * Sends from an authoritative / stable IP, supporting StartTLS, and with 26 | client certificates proving the authoritative sender. 27 | * Mail DKIM signed with a key that isn't on the authoritative server. 28 | 29 | Design 30 | --- 31 | gosendmail provides two binaries which together provide: 32 | 33 | * Santitizing / writing a message ID to identify a new message. 34 | * DKIM signing / authorization of email. 35 | * Finding the destination mail server(s) for the message. 36 | * Speaking the SMTP protocol over secured TLS for delivery. 37 | 38 | `signmail` parses stdin for mail sent via a `mutt`-like program. 39 | It runs santization and dkim signing, and then passes the signed message 40 | to another process, based on the sending domain. The sub-process 41 | interaction is designed to be interoperable with the semantics of 42 | `sendmail`. (e.g. the final email body is passed to stdin.) 43 | 44 | `sendmail` runs on a semi-trusted server, takes an already signed message, 45 | and performs the actual sending to remote MTSs. 46 | 47 | Usage 48 | --- 49 | 50 | * `go build ./cmd/sendmail ./cmd/signmail` 51 | * copy `sendmail` to your cloud server (or build it there). 52 | * Modify `config.json` for relevant keys and domain(s). 53 | * Configure Mutt or your MTA to send using the `signmail` binary. 54 | * Use the environmental variables `GOSENDMAIL_TLS` and `GOSENDMAIL_SELFSIGNED` 55 | when insecure mail delivery is desirable. These variable will be read by 56 | the sendmail binary, a can be propagated through SSH. 57 | 58 | Configuration Options 59 | --- 60 | 61 | Environmental Variables 62 | 63 | * `GOSENDMAIL_TLS` - set to a false-y ("false", "0") value to skip StartTLS 64 | * `GOSENDMAIL_SELFSIGNED` - set to a true value ("true", "1") to allow 65 | TLS handshakes with servers that present invalid certificates. 66 | * `GOSENDMAIL_RECIPIENTS` - overrides the addresses the message will be sent 67 | to. This helps support partial resumption of remaining recipients and BCC. 68 | If not specified, recipients will be loaded from the To, CC, and BCC fields. 69 | 70 | Configuration Options (signmail) 71 | 72 | * `DkimKeyCmd` The subprocess to execute to retrieve the bytes of the dkim signing key. 73 | * `DkimSelector` The DKIM selector, a part of the DKIM dns record. (default: 'default') 74 | * `SendCommand` The subprocess to use to send signed messages via the semi-trusted server. 75 | 76 | Configuration Options (sendmail) 77 | 78 | * `DialerProxy` A URL (e.g. `socks5://...`) that connections to remote MTAs will be 79 | dialed through. 80 | * `TLSCert` The certificate file for the sender (client) to use for self authentication. 81 | * `TLSKey` The corresponding private key file for the sending client to use. 82 | 83 | DKIM setup 84 | --- 85 | 86 | ``` 87 | openssl genpkey -algorithm ed25519 -out domain.dkim.pem 88 | openssl pkey -in domain.dkim.pem -pubout -out domain.dkim.pub 89 | P=openssl asn1parse -in domain.dkim.pub -offset 12 -noout -out /dev/stdout | openssl base64 90 | ``` 91 | then the record is `v=DKIM1; k=ed25519; p=$P` -------------------------------------------------------------------------------- /cache/messagecache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "path" 8 | 9 | "github.com/spf13/viper" 10 | "github.com/willscott/gosendmail/lib" 11 | ) 12 | 13 | // MessageCache represents the email messages for which a send has been 14 | // attempted and either failed to a queue or partially sent. 15 | type MessageCache []lib.ParsedMessage 16 | 17 | // Save seralizes the MessageCache to a canonical disk location. 18 | func (m *MessageCache) Save() error { 19 | confPath := path.Join(path.Dir(viper.ConfigFileUsed()), "inflight.json") 20 | for _, msg := range *m { 21 | if err := msg.Save(); err != nil { 22 | return err 23 | } 24 | } 25 | b, err := json.Marshal(m) 26 | if err != nil { 27 | return err 28 | } 29 | var out bytes.Buffer 30 | json.Indent(&out, b, "", " ") 31 | return os.WriteFile(confPath, out.Bytes(), 0600) 32 | } 33 | 34 | // Unlink deletes the message cache from disk 35 | func (m *MessageCache) Unlink() error { 36 | confPath := path.Join(path.Dir(viper.ConfigFileUsed()), "inflight.json") 37 | if _, err := os.Stat(confPath); os.IsNotExist(err) { 38 | return nil 39 | } 40 | return os.Remove(confPath) 41 | } 42 | 43 | // LoadMessageCache attempts to load in-flight messages from their canonical 44 | // disk location. 45 | func LoadMessageCache() (MessageCache, error) { 46 | confPath := path.Join(path.Dir(viper.ConfigFileUsed()), "inflight.json") 47 | bytes, err := os.ReadFile(confPath) 48 | if err != nil { 49 | return nil, err 50 | } 51 | cache := new(MessageCache) 52 | if err = json.Unmarshal(bytes, cache); err != nil { 53 | return nil, err 54 | } 55 | return *cache, nil 56 | } 57 | -------------------------------------------------------------------------------- /cache/messagecache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | "github.com/willscott/gosendmail/lib" 10 | ) 11 | 12 | func TestMessageCache(t *testing.T) { 13 | content, err := os.ReadFile("../lib/testdata/test.eml") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | // Store serialization in a temporary directory. 19 | viper.SetConfigFile(t.TempDir()) 20 | 21 | msg := lib.ParseMessage(&content) 22 | 23 | cache := MessageCache{msg} 24 | if err = cache.Save(); err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | newCache, err := LoadMessageCache() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | if len(newCache) != 1 || strings.Compare(newCache[0].Recipients(), msg.Recipients()) != 0 { 33 | t.Fatalf("cache not durable") 34 | } 35 | 36 | newCache[0].Unlink() 37 | newCache.Unlink() 38 | } 39 | -------------------------------------------------------------------------------- /cmd/relay/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/willscott/gosendmail/cmd/relay 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/3th1nk/cidr v0.2.0 7 | github.com/flashmob/go-guerrilla v1.6.1 8 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 9 | github.com/spf13/viper v1.18.2 10 | ) 11 | 12 | require ( 13 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef // indirect 14 | github.com/fsnotify/fsnotify v1.7.0 // indirect 15 | github.com/go-sql-driver/mysql v1.7.1 // indirect 16 | github.com/hashicorp/hcl v1.0.0 // indirect 17 | github.com/magiconair/properties v1.8.7 // indirect 18 | github.com/mitchellh/mapstructure v1.5.0 // indirect 19 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 20 | github.com/sagikazarmark/locafero v0.4.0 // indirect 21 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 22 | github.com/sirupsen/logrus v1.9.3 // indirect 23 | github.com/sourcegraph/conc v0.3.0 // indirect 24 | github.com/spf13/afero v1.11.0 // indirect 25 | github.com/spf13/cast v1.6.0 // indirect 26 | github.com/spf13/pflag v1.0.5 // indirect 27 | github.com/subosito/gotenv v1.6.0 // indirect 28 | go.uber.org/atomic v1.9.0 // indirect 29 | go.uber.org/multierr v1.9.0 // indirect 30 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 31 | golang.org/x/sys v0.16.0 // indirect 32 | golang.org/x/text v0.14.0 // indirect 33 | gopkg.in/ini.v1 v1.67.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /cmd/relay/go.sum: -------------------------------------------------------------------------------- 1 | github.com/3th1nk/cidr v0.2.0 h1:81jjEknszD8SHPLVTPPk+BZjNVqq1ND2YXLSChl6Lrs= 2 | github.com/3th1nk/cidr v0.2.0/go.mod h1:XsSQnS4rEYyB2veDfnIGgViulFpIITPKtp3f0VxpiLw= 3 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef h1:2JGTg6JapxP9/R33ZaagQtAM4EkkSYnIAlOG5EI8gkM= 4 | github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7hed4L1fj0hXcyEejnW57/7LCetXggd+vwrRnYeII= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/flashmob/go-guerrilla v1.6.1 h1:MLkqzRFUJveVAWuQ3s2MNPTAWbvXLt8EFsBoraS6qHA= 10 | github.com/flashmob/go-guerrilla v1.6.1/go.mod h1:ZT9TRggRsSY4ZVndoyx8TRUxi3tM/nOYtKWKDX94H0I= 11 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 12 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 13 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 14 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 15 | github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= 16 | github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 20 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 21 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 22 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 28 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 29 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 30 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 31 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= 32 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 35 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 37 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 38 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 39 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 40 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 41 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 42 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 43 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 44 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 45 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 46 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 47 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 48 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 49 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 50 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 51 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 52 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 53 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 61 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 62 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 63 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 64 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 65 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 66 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 67 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 68 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 69 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 70 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 71 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 72 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 74 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 75 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 76 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 77 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 79 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 80 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 81 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 82 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 83 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 84 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | -------------------------------------------------------------------------------- /cmd/relay/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/flashmob/go-guerrilla" 16 | "github.com/flashmob/go-guerrilla/backends" 17 | "github.com/flashmob/go-guerrilla/log" 18 | "github.com/flashmob/go-guerrilla/mail" 19 | "github.com/flashmob/go-guerrilla/response" 20 | "github.com/kballard/go-shellquote" 21 | 22 | "github.com/3th1nk/cidr" 23 | "github.com/spf13/viper" 24 | ) 25 | 26 | var ( 27 | signalChannel = make(chan os.Signal, 1) 28 | d guerrilla.Daemon 29 | 30 | mainlog log.Logger 31 | ) 32 | 33 | func sigHandler() { 34 | // handle SIGHUP for reloading the configuration while running 35 | signal.Notify(signalChannel, 36 | syscall.SIGHUP, 37 | syscall.SIGTERM, 38 | syscall.SIGQUIT, 39 | syscall.SIGINT, 40 | syscall.SIGUSR1, 41 | ) 42 | // Keep the daemon busy by waiting for signals to come 43 | for sig := range signalChannel { 44 | if sig == syscall.SIGHUP { 45 | viper.ReadInConfig() 46 | cf := viper.GetViper().ConfigFileUsed() 47 | d.ReloadConfigFile(cf) 48 | } else if sig == syscall.SIGUSR1 { 49 | d.ReopenLogs() 50 | } else if sig == syscall.SIGTERM || sig == syscall.SIGQUIT || sig == syscall.SIGINT { 51 | d.Shutdown() 52 | return 53 | } else { 54 | return 55 | } 56 | } 57 | } 58 | 59 | func init() { 60 | // log to stderr on startup 61 | var err error 62 | mainlog, err = log.GetLogger(log.OutputStderr.String(), log.InfoLevel.String()) 63 | if err != nil { 64 | mainlog.WithError(err).Errorf("Failed creating a logger to %s", log.OutputStderr) 65 | } 66 | } 67 | 68 | // Relay is a go-guerrilla processor that forwards on inbound SMTP messages. 69 | // Designed for a satellite host, where messages from internal VMs / machines 70 | // should be forwarded out to an external collector / MTA 71 | func main() { 72 | // get config 73 | viper.AddConfigPath("$HOME/.gosendmailrelay") 74 | viper.AddConfigPath(".") 75 | viper.SetDefault("tls", true) 76 | viper.SetDefault("selfsigned", false) 77 | viper.SetEnvPrefix("gosendmail") 78 | viper.AutomaticEnv() 79 | err := viper.ReadInConfig() 80 | if err != nil { 81 | mainlog.Fatal(err) 82 | } 83 | 84 | // start up the server. 85 | d = guerrilla.Daemon{Logger: mainlog} 86 | d.AddProcessor("Relay", Processor) 87 | d.AddProcessor("Gate", Auth) 88 | 89 | cf := viper.GetViper().ConfigFileUsed() 90 | if _, err := d.LoadConfig(cf); err != nil { 91 | mainlog.Fatal(err) 92 | } 93 | if err != nil { 94 | mainlog.WithError(err).Fatal("Error while reading config") 95 | } 96 | // Check that max clients is not greater than system open file limit. 97 | fileLimit := getFileLimit() 98 | if fileLimit > 0 { 99 | maxClients := 0 100 | for _, s := range d.Config.Servers { 101 | maxClients += s.MaxClients 102 | } 103 | if maxClients > fileLimit { 104 | mainlog.Fatalf("Combined max clients for all servers (%d) is greater than open file limit (%d). "+ 105 | "Please increase your open file limit or decrease max clients.", maxClients, fileLimit) 106 | } 107 | } 108 | 109 | err = d.Start() 110 | if err != nil { 111 | mainlog.WithError(err).Error("Error(s) when starting server(s)") 112 | os.Exit(1) 113 | } 114 | 115 | sigHandler() 116 | } 117 | 118 | var Auth = func() backends.Decorator { 119 | return func(c backends.Processor) backends.Processor { 120 | allowedNetStr := viper.GetString("AllowedNetwork") 121 | allowNet := &cidr.CIDR{} 122 | if len(allowedNetStr) > 0 { 123 | var err error 124 | allowNet, err = cidr.Parse(allowedNetStr) 125 | if err != nil { 126 | mainlog.Fatalf("Fatal: failed to parse allowed networks %s\n", err) 127 | return nil 128 | } 129 | } 130 | 131 | return backends.ProcessWith(func(e *mail.Envelope, task backends.SelectTask) (backends.Result, error) { 132 | remote := e.RemoteIP 133 | if !allowNet.Contains(remote) { 134 | mainlog.Infof("Rejecting message from %s\n", remote) 135 | if task == backends.TaskSaveMail { 136 | return backends.NewResult(response.Canned.ErrorRelayDenied), backends.RcptError(errors.New("not allowed to send mail")) 137 | } else { // validation 138 | return backends.NewResult(response.Canned.ErrorRelayDenied), backends.RcptError(errors.New("not allowed to send mail")) 139 | } 140 | } 141 | return c.Process(e, task) 142 | }) 143 | } 144 | } 145 | 146 | // Processor allows the 'relay' option for guerrilla 147 | var Processor = func() backends.Decorator { 148 | return func(c backends.Processor) backends.Processor { 149 | sc := viper.GetString("SendCommand") 150 | shq, err := shellquote.Split(sc) 151 | if err != nil { 152 | mainlog.Fatalf("Fatal: failed to parse send command %s\n", err) 153 | return nil 154 | } 155 | 156 | // The function will be called on each email transaction. 157 | // On success, it forwards to the next step in the processor call-stack, 158 | // or returns with an error if failed 159 | return backends.ProcessWith(func(e *mail.Envelope, task backends.SelectTask) (backends.Result, error) { 160 | if task == backends.TaskSaveMail { 161 | ctx, cncl := context.WithTimeout(context.Background(), 10*time.Second) 162 | defer cncl() 163 | child := exec.CommandContext(ctx, shq[0], shq[1:]...) 164 | 165 | child.Stdin = &e.Data 166 | dlp := e.RcptTo 167 | destList := "" 168 | for _, dest := range dlp { 169 | if destList != "" { 170 | destList = destList + ", " + dest.String() 171 | } else { 172 | destList = "" + dest.String() 173 | } 174 | } 175 | child.Env = []string{"GOSENDMAIL_FROM=" + e.MailFrom.String(), "GOSENDMAIL_RECIPIENTS=" + destList} 176 | out, err := child.CombinedOutput() 177 | if err != nil { 178 | mainlog.WithError(fmt.Errorf("sendmail err %w: %s", err, out)).Errorf("Failed to send mail") 179 | return backends.NewResult("550 5.7.1 Failed to send mail", 550), nil 180 | } 181 | return backends.NewResult("250 2.0.0 OK: queued", 250), nil 182 | } 183 | return c.Process(e, task) 184 | }) 185 | } 186 | } 187 | 188 | func getFileLimit() int { 189 | cmd := exec.Command("ulimit", "-n") 190 | out, err := cmd.Output() 191 | if err != nil { 192 | return -1 193 | } 194 | limit, err := strconv.Atoi(strings.TrimSpace(string(out))) 195 | if err != nil { 196 | return -1 197 | } 198 | return limit 199 | } 200 | -------------------------------------------------------------------------------- /cmd/sendmail/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "os" 8 | 9 | "github.com/spf13/viper" 10 | "github.com/willscott/gosendmail/lib" 11 | ) 12 | 13 | // Sendmail negotiates a series of SMTP connections with remote servers 14 | // to deliver a message sent on stdin. It is stateless. 15 | // 16 | // The output (log.Fatalf / log.Printf) from this process are parsed 17 | // by lib/log.go in the signmail commanding process. 18 | // The expected convention is that lines follow one of three formats: 19 | // * "Info: " - ignored 20 | // * "Delivered: " - indication of successful delivery 21 | // * "Fatal: " - indication that an error occured 22 | func main() { 23 | // get config 24 | viper.AddConfigPath("$HOME/.gosendmail") 25 | viper.AddConfigPath(".") 26 | viper.SetDefault("tls", true) 27 | viper.SetDefault("selfsigned", false) 28 | viper.SetDefault("recipients", "") 29 | viper.SetEnvPrefix("gosendmail") 30 | viper.AutomaticEnv() 31 | err := viper.ReadInConfig() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | // get mail as input 37 | msg := lib.ReadMessage(os.Stdin) 38 | 39 | // Parse msg 40 | parsed := lib.ParseMessage(&msg) 41 | 42 | cfg := lib.GetConfig(parsed.SourceDomain) 43 | if cfg == nil { 44 | log.Fatalf("Fatal: No configuration for sender %s\n", parsed.SourceDomain) 45 | } 46 | 47 | rcptOverride := viper.GetString("recipients") 48 | if rcptOverride != "" { 49 | parsed.SetRecipients(rcptOverride) 50 | } 51 | 52 | for _, dest := range parsed.DestDomain { 53 | log.Printf("Info: connecting to %s\n", dest) 54 | SendTo(dest, &parsed, cfg, msg, viper.GetBool("tls"), viper.GetBool("selfsigned")) 55 | } 56 | log.Printf("Info: finished\n") 57 | } 58 | 59 | func SendTo(dest string, parsed *lib.ParsedMessage, cfg *lib.Config, msg []byte, tls bool, selfSigned bool) { 60 | // enumerate possible mx IPs 61 | hosts := lib.FindServers(dest) 62 | 63 | // open connection 64 | conn, hostname := lib.DialFromList(hosts, cfg) 65 | helloSrc := parsed.SourceDomain 66 | if len(cfg.SourceHost) > 0 { 67 | helloSrc = cfg.SourceHost 68 | } 69 | if err := conn.Hello(helloSrc); err != nil { 70 | log.Fatalf("Fatal: negotiating hello with %s: %v", hostname, err) 71 | } 72 | 73 | // try ssl upgrade 74 | if tls { 75 | if err := lib.StartTLS(conn, hostname, cfg, selfSigned); err != nil { 76 | log.Fatalf("Fatal: negotiating starttls with %s: %v", hostname, err) 77 | } 78 | } 79 | 80 | // send email 81 | if err := conn.Mail(parsed.Sender); err != nil { 82 | log.Fatalf("Fatal: setting mailfrom: %v\n", err) 83 | } 84 | 85 | rcpts := "" 86 | for _, rcpt := range parsed.Rcpt[dest] { 87 | if err := conn.Rcpt(rcpt); err != nil { 88 | log.Fatalf("Fatal: setting rcpt %s: %v\n", rcpt, err) 89 | } 90 | if rcpts != "" { 91 | rcpts = rcpts + ", " 92 | } 93 | rcpts = rcpts + rcpt 94 | } 95 | 96 | // Send the email body. 97 | wc, err := conn.Data() 98 | if err != nil { 99 | log.Fatalf("Fatal: sending data: %v\n", err) 100 | } 101 | 102 | if _, err := io.Copy(wc, bytes.NewReader(msg)); err != nil { 103 | log.Fatalf("Fatal: copying bytes of body: %v\n", err) 104 | } 105 | err = wc.Close() 106 | if err != nil { 107 | log.Fatalf("Fatal: concluding data: %v\n", err) 108 | } 109 | 110 | log.Printf("Delivered: %s\n", rcpts) 111 | 112 | // Send the QUIT command and close the connection. 113 | conn.Quit() 114 | } 115 | -------------------------------------------------------------------------------- /cmd/signmail/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | flag "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | "github.com/willscott/gosendmail/cache" 15 | "github.com/willscott/gosendmail/lib" 16 | ) 17 | 18 | func init() { 19 | flag.CommandLine.BoolP("queue", "s", false, "Store message to queue if not sent successfully") 20 | flag.CommandLine.BoolP("resume", "r", false, "Attempt delivery of queued messages") 21 | flag.CommandLine.StringP("from", "f", "", "Use explicit sender separate from the address parsed in the msg") 22 | } 23 | 24 | func main() { 25 | // get config 26 | viper.AddConfigPath("$HOME/.gosendmail") 27 | viper.AddConfigPath(".") 28 | err := viper.ReadInConfig() 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | viper.SetEnvPrefix("gosendmail") 33 | viper.AutomaticEnv() 34 | if flag.CommandLine.Parse(os.Args[1:]) != nil { 35 | flag.CommandLine.Usage() 36 | return 37 | } 38 | if err := viper.BindPFlags(flag.CommandLine); err != nil { 39 | log.Fatal(err) 40 | } 41 | explicitFrom := viper.GetString("from") 42 | explicitTo := viper.GetString("recipients") 43 | 44 | if viper.GetBool("resume") { 45 | mc, err := cache.LoadMessageCache() 46 | if err != nil { 47 | log.Fatalf("Failed to load queue: %v", err) 48 | } 49 | newMC := new(cache.MessageCache) 50 | for _, parsed := range mc { 51 | err = trySend(parsed) 52 | if err != nil { 53 | *newMC = append(*newMC, parsed) 54 | log.Printf("Delivery failure: %v", err) 55 | } else { 56 | if err = parsed.Unlink(); err != nil { 57 | log.Printf("Failed to remove cached message: %v", err) 58 | } 59 | } 60 | } 61 | err = newMC.Save() 62 | if err != nil { 63 | log.Fatalf("Failed to save queue: %v", err) 64 | } 65 | } else { 66 | // get mail as input 67 | msg := lib.ReadMessage(os.Stdin) 68 | 69 | // Parse msg 70 | parsed := lib.ParseMessage(&msg) 71 | if len(explicitFrom) > 0 { 72 | if err = parsed.SetSender(explicitFrom); err != nil { 73 | log.Fatalf("Failed to prepare message: %v", err) 74 | } 75 | } 76 | if len(explicitTo) > 0 { 77 | if err = parsed.SetRecipients(explicitTo); err != nil { 78 | log.Fatalf("Failed to prepare message: %v", err) 79 | } 80 | } 81 | if err = prepareMessage(parsed); err != nil { 82 | log.Fatalf("Failed to prepare message: %v", err) 83 | } 84 | 85 | err := trySend(parsed) 86 | if err != nil { 87 | if viper.GetBool("queue") { 88 | log.Printf("Failed to send message: %v", err) 89 | mc, err := cache.LoadMessageCache() 90 | if err != nil { 91 | log.Fatalf("Failed to load cache: %v", err) 92 | } 93 | mc = append(mc, parsed) 94 | if err = mc.Save(); err != nil { 95 | log.Fatalf("Failed to save cache: %v", err) 96 | } 97 | } else { 98 | log.Fatalf("Failed to send message: %v", err) 99 | } 100 | } 101 | } 102 | } 103 | 104 | func prepareMessage(parsed lib.ParsedMessage) error { 105 | cfg := lib.GetConfig(parsed.SourceDomain) 106 | if cfg == nil { 107 | return fmt.Errorf("no configuration for sender %s", parsed.SourceDomain) 108 | } 109 | 110 | if err := lib.SanitizeMessage(parsed, cfg); err != nil { 111 | return err 112 | } 113 | 114 | if cfg.DkimKeyCmd != "" { 115 | if err := lib.SignMessage(parsed, cfg); err != nil { 116 | return err 117 | } 118 | } 119 | return nil 120 | } 121 | 122 | func trySend(parsed lib.ParsedMessage) error { 123 | cfg := lib.GetConfig(parsed.SourceDomain) 124 | if cfg == nil { 125 | return fmt.Errorf("no configuration for sender %s", parsed.SourceDomain) 126 | } 127 | 128 | // send to remote server. 129 | keycmd := strings.Split(cfg.SendCommand, " ") 130 | cmd := exec.Command(keycmd[0], keycmd[1:]...) 131 | cmd.Env = append(os.Environ(), 132 | "GOSENDMAIL_RECIPIENTS="+parsed.Recipients()) 133 | stdin, err := cmd.StdinPipe() 134 | if err != nil { 135 | return err 136 | } 137 | 138 | go func() { 139 | defer stdin.Close() 140 | io.Copy(stdin, bytes.NewReader(*parsed.Bytes)) 141 | }() 142 | 143 | l, err := cmd.CombinedOutput() 144 | lib.InterpretLog(string(l), &parsed) 145 | if err != nil { 146 | return fmt.Errorf("%s: %v", l, err) 147 | } 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "WriteToDisk": "cat", 3 | "ReadFromDisk": "cat", 4 | 5 | "insecure.com": { 6 | "DkimKeyCmd": "cat example.pem", 7 | "DkimSelector": "default", 8 | "SendCommand": "gosendmail" 9 | }, 10 | "secure.com": { 11 | "DkimKeyCmd": "gpg example.pem", 12 | "DkimSelector": "default", 13 | "SendCommand": "ssh myserver gosendmail" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/willscott/gosendmail 2 | 3 | go 1.21.0 4 | 5 | require ( 6 | github.com/spf13/pflag v1.0.5 7 | github.com/spf13/viper v1.18.2 8 | github.com/willscott/go-dkim v0.0.0-20240117163537-77cb5174ba32 9 | golang.org/x/net v0.20.0 10 | ) 11 | 12 | require ( 13 | github.com/fsnotify/fsnotify v1.7.0 // indirect 14 | github.com/hashicorp/hcl v1.0.0 // indirect 15 | github.com/magiconair/properties v1.8.7 // indirect 16 | github.com/mitchellh/mapstructure v1.5.0 // indirect 17 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 18 | github.com/sagikazarmark/locafero v0.4.0 // indirect 19 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 20 | github.com/sourcegraph/conc v0.3.0 // indirect 21 | github.com/spf13/afero v1.11.0 // indirect 22 | github.com/spf13/cast v1.6.0 // indirect 23 | github.com/subosito/gotenv v1.6.0 // indirect 24 | go.uber.org/atomic v1.9.0 // indirect 25 | go.uber.org/multierr v1.9.0 // indirect 26 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 27 | golang.org/x/sys v0.16.0 // indirect 28 | golang.org/x/text v0.14.0 // indirect 29 | gopkg.in/ini.v1 v1.67.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 6 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 7 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 8 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 9 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 10 | github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 11 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 12 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 14 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 15 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 16 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 20 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 21 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 22 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 23 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= 24 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 27 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 29 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 30 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 31 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 32 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 33 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 34 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 35 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 36 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 37 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 38 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 39 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 40 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 41 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 42 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 43 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 44 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 45 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 46 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 47 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 48 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 49 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 50 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 51 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 52 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 53 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 54 | github.com/willscott/go-dkim v0.0.0-20240117162907-7ada29704d1a h1:/LQA6ame6/uS7EgynBsTU0gZ3H2LCbZ7X0sEz3I8Yrg= 55 | github.com/willscott/go-dkim v0.0.0-20240117162907-7ada29704d1a/go.mod h1:3Qofc8lHgKIGaJ9anxryJgSbEw35nkqX0BvgxhQ/vbE= 56 | github.com/willscott/go-dkim v0.0.0-20240117163537-77cb5174ba32 h1:m8GcA1LkdwUeku2QyKocXeRfjpj/Z1H0lf05ypBQGC8= 57 | github.com/willscott/go-dkim v0.0.0-20240117163537-77cb5174ba32/go.mod h1:3Qofc8lHgKIGaJ9anxryJgSbEw35nkqX0BvgxhQ/vbE= 58 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 59 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 60 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 61 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 62 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 63 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 64 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 65 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 66 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 67 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 68 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 69 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 72 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 73 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 74 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 75 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /lib/config.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | // Config represents the structure of a single domain configuration 15 | // in config.json 16 | type Config struct { 17 | DkimKeyCmd string 18 | DkimSelector string 19 | DialerProxy string 20 | SourceHost string 21 | TLSCert string 22 | TLSKey string 23 | tlscfg *tls.Config 24 | SendCommand string 25 | } 26 | 27 | // GetTLS returns a TLS configuration (the epxected certificate and server name) 28 | // for a given configured domain. 29 | func (c *Config) GetTLS() *tls.Config { 30 | if c.tlscfg != nil { 31 | return c.tlscfg 32 | } 33 | if c.TLSCert != "" { 34 | cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | c.tlscfg = &tls.Config{Certificates: []tls.Certificate{cert}} 39 | } else { 40 | c.tlscfg = &tls.Config{} 41 | } 42 | 43 | return c.tlscfg 44 | } 45 | 46 | // GetConfig looks for a domain in the currently loaded configuration 47 | // and attempts to parse it as into a Config struct. 48 | func GetConfig(domain string) *Config { 49 | config := viper.Get(domain) 50 | if config == nil { 51 | return nil 52 | } 53 | 54 | cfgMap, ok := config.(map[string]interface{}) 55 | if !ok { 56 | return nil 57 | } 58 | 59 | if alias, ok := cfgMap["alias"].(string); ok { 60 | config = viper.Get(alias) 61 | cfgMap, _ = config.(map[string]interface{}) 62 | } 63 | 64 | cfg := Config{} 65 | if dkim, ok := cfgMap["dkimkeycmd"].(string); ok { 66 | cfg.DkimKeyCmd = dkim 67 | } 68 | if dkimselector, ok := cfgMap["dkimselector"].(string); ok { 69 | cfg.DkimSelector = dkimselector 70 | } 71 | if proxy, ok := cfgMap["dialerproxy"].(string); ok { 72 | cfg.DialerProxy = proxy 73 | } 74 | if sendCommand, ok := cfgMap["sendcommand"].(string); ok { 75 | cfg.SendCommand = sendCommand 76 | } 77 | if cert, ok := cfgMap["tlscert"].(string); ok { 78 | cfg.TLSCert = cert 79 | } 80 | if key, ok := cfgMap["tlskey"].(string); ok { 81 | cfg.TLSKey = key 82 | } 83 | if sourceHost, ok := cfgMap["sourcehost"].(string); ok { 84 | cfg.SourceHost = sourceHost 85 | } 86 | 87 | return &cfg 88 | } 89 | 90 | // ParseDiskInput reads a filename, transforming the data with a configured 91 | // 'ReadFromDisk' command if set. This allows messages to be passed through 92 | // a gpg encryption process if desired. 93 | func ParseDiskInput(filename string) ([]byte, error) { 94 | cfg := viper.Get("ReadFromDisk") 95 | if cfg == nil { 96 | return os.ReadFile(filename) 97 | } 98 | 99 | readCmdLine, ok := cfg.(string) 100 | if !ok { 101 | return os.ReadFile(filename) 102 | } 103 | 104 | file, err := os.Open(filename) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | readCmdArgs := strings.Split(readCmdLine, " ") 110 | readCmd := exec.Command(readCmdArgs[0], readCmdArgs[1:]...) 111 | readCmd.Stdin = file 112 | return readCmd.Output() 113 | } 114 | 115 | // WriteDiskOutput writes a bytestring to a desired file on disk, transforming 116 | // the data through a configured `WriteToDisk` command if set. This allows 117 | // messages to be passed through a gpg encryption process if desired. 118 | func WriteDiskOutput(filename string, data []byte) error { 119 | cfg := viper.Get("WriteToDisk") 120 | if cfg == nil { 121 | return os.WriteFile(filename, data, 0600) 122 | } 123 | 124 | writeCmdLine, ok := cfg.(string) 125 | if !ok { 126 | return os.WriteFile(filename, data, 0600) 127 | } 128 | 129 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0600) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | writeCmdArgs := strings.Split(writeCmdLine, " ") 135 | writeCmd := exec.Command(writeCmdArgs[0], writeCmdArgs[1:]...) 136 | writeCmd.Stdin = bytes.NewReader(data) 137 | writeCmd.Stdout = file 138 | return writeCmd.Run() 139 | } 140 | -------------------------------------------------------------------------------- /lib/log.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // InterpretLog matches the output of a `sendmail` command 8 | // for a given ParsedMessage. Output lines indicating the 9 | // message was delivered are used to remove remaining recipients 10 | // where redelivery is needed. 11 | func InterpretLog(l string, parsed *ParsedMessage) { 12 | lines := strings.Split(l, "\n") 13 | for _, line := range lines { 14 | if strings.HasPrefix(line, "Fatal") { 15 | return 16 | } else if strings.HasPrefix(line, "Info") { 17 | continue 18 | } else if strings.HasPrefix(line, "Delivered:") { 19 | // remove rcpts. from parsed. 20 | rcpts := line[10:] 21 | parsed.RemoveRecipients(rcpts) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/message.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "errors" 9 | "io" 10 | "log" 11 | "net/mail" 12 | "os" 13 | "path" 14 | "strings" 15 | 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | func splitAddress(email string) (account, host string) { 20 | i := strings.LastIndexByte(email, '@') 21 | account = email[:i] 22 | host = email[i+1:] 23 | return 24 | } 25 | 26 | func joinAddresses(addresses []*mail.Address) string { 27 | out := "" 28 | for _, addr := range addresses { 29 | if addr != nil { 30 | if out != "" { 31 | out = out + ", " + addr.String() 32 | } else { 33 | out = addr.String() 34 | } 35 | } 36 | } 37 | return out 38 | } 39 | 40 | // ReadMessage scans a given io.Reader into a []byte. 41 | func ReadMessage(reader io.Reader) []byte { 42 | scanner := bufio.NewScanner(reader) 43 | scanner.Split(bufio.ScanBytes) 44 | mailMsg := make([]byte, 0) 45 | for scanner.Scan() { 46 | mailMsg = append(mailMsg, scanner.Bytes()...) 47 | } 48 | if err := scanner.Err(); err != nil && err != io.EOF { 49 | log.Fatalf("reading message: %v", err) 50 | } 51 | return mailMsg 52 | } 53 | 54 | // ParsedMessage represents a semi-structred email message. 55 | type ParsedMessage struct { 56 | Sender string 57 | SourceDomain string 58 | Rcpt map[string][]string 59 | DestDomain []string 60 | Bytes *[]byte 61 | *mail.Message 62 | } 63 | 64 | // Hash provides an ideally stable handle for a message. 65 | func (p ParsedMessage) Hash() string { 66 | hasher := sha256.New() 67 | 68 | // We re-parse bytes here because `msg.Body` can only be read once. 69 | m, err := mail.ReadMessage(bytes.NewReader(*p.Bytes)) 70 | if err != nil { 71 | return "" 72 | } 73 | 74 | io.Copy(hasher, m.Body) 75 | return hex.EncodeToString(hasher.Sum(nil)) 76 | } 77 | 78 | // FileName provides a stable location on disk for the message to serialize to. 79 | func (p ParsedMessage) FileName() string { 80 | return path.Join(path.Dir(viper.ConfigFileUsed()), p.Hash()+".eml") 81 | } 82 | 83 | // UnmarshalText attempts to load a message from a textual pointer of its state. 84 | func (p *ParsedMessage) UnmarshalText(b []byte) error { 85 | // attempt loading file from hash. 86 | hash := bytes.Index(b, []byte(" ")) 87 | if hash == -1 { 88 | return errors.New("invalid cache line") 89 | } 90 | filename := path.Join(path.Dir(viper.ConfigFileUsed()), string(b[0:hash])+".eml") 91 | 92 | dat, err := ParseDiskInput(filename) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | msg := ParseMessage(&dat) 98 | p.Bytes = &dat 99 | p.Sender = msg.Sender 100 | p.SourceDomain = msg.SourceDomain 101 | p.Message = msg.Message 102 | 103 | // set recipients. 104 | return p.SetRecipients(string(b[hash+1:])) 105 | } 106 | 107 | // MarshalText provides a textual handle of the message. The message contents is 108 | // not included, and must be saved using `Save` for the marshal'ed handle to be 109 | // considered durable. 110 | func (p ParsedMessage) MarshalText() ([]byte, error) { 111 | // line format: 112 | return []byte(p.Hash() + " " + p.Recipients()), nil 113 | } 114 | 115 | // Save message to disk. 116 | // TODO: support encryption of on-disk data. 117 | func (p *ParsedMessage) Save() error { 118 | return WriteDiskOutput(p.FileName(), *p.Bytes) 119 | } 120 | 121 | // Unlink message data from disk if present. 122 | func (p *ParsedMessage) Unlink() error { 123 | if _, err := os.Stat(p.FileName()); os.IsNotExist(err) { 124 | return nil 125 | } 126 | return os.Remove(p.FileName()) 127 | } 128 | 129 | // ParseMessage parses a byte array representating an email message 130 | // to learn the sender, and intended recipients. 131 | func ParseMessage(msg *[]byte) ParsedMessage { 132 | m, err := mail.ReadMessage(bytes.NewReader(*msg)) 133 | if err != nil { 134 | log.Fatalf("reading msg: %v", err) 135 | } 136 | 137 | header := m.Header 138 | 139 | // parse out from 140 | ap := mail.AddressParser{} 141 | sender, err := ap.Parse(header.Get("From")) 142 | if err != nil { 143 | log.Fatal(err) 144 | } 145 | _, fromHost := splitAddress(sender.Address) 146 | 147 | // parse rcpt and dests from to / cc / bcc 148 | addrs := make([]*mail.Address, 0, 5) 149 | for _, f := range []string{"To", "CC", "BCC"} { 150 | for _, line := range header[f] { 151 | dests, err := ap.ParseList(line) 152 | if err != nil { 153 | log.Fatal(err) 154 | } 155 | addrs = append(addrs, dests...) 156 | } 157 | } 158 | defaultReceipients := joinAddresses(addrs) 159 | 160 | pm := ParsedMessage{ 161 | Sender: sender.Address, 162 | SourceDomain: fromHost, 163 | Message: m, 164 | Bytes: msg, 165 | } 166 | 167 | pm.SetRecipients(defaultReceipients) 168 | return pm 169 | } 170 | 171 | // SetSender specifies an explicit sending email account for the message. 172 | func (p *ParsedMessage) SetSender(sender string) error { 173 | _, fromHost := splitAddress(sender) 174 | p.Sender = sender 175 | p.SourceDomain = fromHost 176 | return nil 177 | } 178 | 179 | // SetRecipients sets the accounts and corresponding domains to which 180 | // the email will be sent. 181 | func (p *ParsedMessage) SetRecipients(recipients string) error { 182 | ap := mail.AddressParser{} 183 | dests, err := ap.ParseList(recipients) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | rcpts := make(map[string][]string) 189 | for _, addr := range dests { 190 | _, toHost := splitAddress(addr.Address) 191 | if _, ok := rcpts[toHost]; !ok { 192 | rcpts[toHost] = make([]string, 0) 193 | } 194 | rcpts[toHost] = append(rcpts[toHost], addr.Address) 195 | } 196 | var hosts []string 197 | for k := range rcpts { 198 | hosts = append(hosts, k) 199 | } 200 | 201 | p.Rcpt = rcpts 202 | p.DestDomain = hosts 203 | return nil 204 | } 205 | 206 | // Recipients gets a comma separated list (AddressList) of recipients. 207 | func (p *ParsedMessage) Recipients() string { 208 | out := "" 209 | for _, dom := range p.Rcpt { 210 | for _, addr := range dom { 211 | if out != "" { 212 | out = out + ", " + addr 213 | } else { 214 | out = addr 215 | } 216 | } 217 | } 218 | return out 219 | } 220 | 221 | // RecipientMap returns a map for identification of recipients, 222 | // where map keys are recipients and map values are `true`. 223 | func (p *ParsedMessage) RecipientMap() map[string]bool { 224 | out := make(map[string]bool, 0) 225 | for _, dom := range p.Rcpt { 226 | for _, addr := range dom { 227 | out[addr] = true 228 | } 229 | } 230 | return out 231 | } 232 | 233 | // RemoveRecipients updates the message.Recipients to no longer 234 | // include a set of addresses specified in AddressList format. 235 | func (p *ParsedMessage) RemoveRecipients(other string) error { 236 | rcptMap := p.RecipientMap() 237 | 238 | otherMsg := ParsedMessage{} 239 | otherMsg.SetRecipients(other) 240 | otherMap := otherMsg.RecipientMap() 241 | out := "" 242 | 243 | for rcpt := range rcptMap { 244 | if _, ok := otherMap[rcpt]; !ok { 245 | if out != "" { 246 | out = out + ", " + rcpt 247 | } else { 248 | out = rcpt 249 | } 250 | } 251 | } 252 | return p.SetRecipients(out) 253 | } 254 | -------------------------------------------------------------------------------- /lib/message_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func TestRecipients(t *testing.T) { 12 | many := "'tom jones' , 'john doe' , 'mary sue' , 'alice' " 13 | m := ParsedMessage{} 14 | if err := m.SetRecipients(many); err != nil { 15 | t.Fatal(err) 16 | } 17 | if err := m.RemoveRecipients("'tom jones' "); err != nil { 18 | t.Fatal(err) 19 | } 20 | remaining := m.RecipientMap() 21 | if len(remaining) != 3 { 22 | t.Fatal("Removal of individual recipient failed") 23 | } 24 | if _, ok := remaining["'tom jones' "]; ok { 25 | t.Fatal("Failed to remove recipient") 26 | } 27 | if len(m.DestDomain) != 2 { 28 | t.Fatal("calculation of domain overlap failed") 29 | } 30 | 31 | if err := m.RemoveRecipients("'john doe' "); err != nil { 32 | t.Fatal(err) 33 | } 34 | if len(m.DestDomain) != 1 { 35 | t.Fatal("calculation of domain overlap failed") 36 | } 37 | } 38 | 39 | func TestSerialize(t *testing.T) { 40 | content, err := os.ReadFile("testdata/test.eml") 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | // Store serialization in a temporary directory. 46 | viper.SetConfigFile(t.TempDir()) 47 | 48 | msg := ParseMessage(&content) 49 | if err = msg.Save(); err != nil { 50 | t.Fatal(err) 51 | } 52 | ptr, err := msg.MarshalText() 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | var recoveredMsg ParsedMessage 58 | err = recoveredMsg.UnmarshalText(ptr) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | if err = recoveredMsg.Unlink(); err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | if strings.Compare(msg.Recipients(), recoveredMsg.Recipients()) != 0 { 67 | t.Fatalf("Failed to recover message recipients") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/net.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/smtp" 8 | "net/url" 9 | "time" 10 | 11 | "golang.org/x/net/proxy" 12 | ) 13 | 14 | func getDialer(cfg *Config) proxy.ContextDialer { 15 | if cfg.DialerProxy != "" { 16 | url, err := url.Parse(cfg.DialerProxy) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | d, err := proxy.FromURL(url, nil) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | p, ok := d.(proxy.ContextDialer) 26 | if !ok { 27 | log.Fatal("Parsed Proxy doesn't support context") 28 | } 29 | return p 30 | } 31 | return &net.Dialer{} 32 | } 33 | 34 | // FindServers resolves the IP addresses of a given destination `domain` 35 | func FindServers(domain string) []string { 36 | resolver := net.Resolver{ 37 | PreferGo: true, 38 | } 39 | 40 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second)) 41 | mxs, err := resolver.LookupMX(ctx, domain) 42 | cancel() 43 | if err != nil { 44 | if dnserr, ok := err.(*net.DNSError); !ok || dnserr.Err != "no such host" { 45 | log.Fatal(err) 46 | } 47 | } 48 | if len(mxs) > 0 { 49 | hosts := make([]string, len(mxs)) 50 | for i, mx := range mxs { 51 | hosts[i] = mx.Host 52 | } 53 | return hosts 54 | } 55 | // fall back to a record. 56 | return []string{domain} 57 | } 58 | 59 | // DialFromList tries dialing in order a list of IPs as if they are email servers until 60 | // exhausting possibilities. 61 | func DialFromList(hosts []string, cfg *Config) (*smtp.Client, string) { 62 | dialer := getDialer(cfg) 63 | 64 | for _, host := range hosts { 65 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second)) 66 | conn, err := dialer.DialContext(ctx, "tcp", host+":smtp") 67 | cancel() 68 | if err == nil { 69 | c, err := smtp.NewClient(conn, host) 70 | if err == nil { 71 | return c, host 72 | } 73 | } 74 | } 75 | 76 | // fall back to 587 - mail submission port 77 | for _, host := range hosts { 78 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second)) 79 | conn, err := dialer.DialContext(ctx, "tcp", host+":587") 80 | cancel() 81 | if err == nil { 82 | c, err := smtp.NewClient(conn, host) 83 | if err == nil { 84 | return c, host 85 | } 86 | } 87 | } 88 | 89 | log.Fatal("Unable to connect to any mail server") 90 | return nil, "" 91 | } 92 | 93 | // StartTLS attempts to upgrade an SMTP network connection with StartTLS. 94 | func StartTLS(conn *smtp.Client, serverName string, cfg *Config, allowSelfSigned bool) error { 95 | tlsCfg := cfg.GetTLS() 96 | tlsCfg.ServerName = serverName 97 | if allowSelfSigned { 98 | tlsCfg.InsecureSkipVerify = true 99 | } 100 | return conn.StartTLS(tlsCfg) 101 | } 102 | -------------------------------------------------------------------------------- /lib/testdata/test.eml: -------------------------------------------------------------------------------- 1 | Date: Sat, 1 Jun 2019 12:47:44 -0700 2 | From: Will Scott 3 | To: will@gmail.com 4 | Subject: testing gosendmail 5 | Message-ID: <20190601194744.armcp575agchpzyu@wills.co.tt> 6 | MIME-Version: 1.0 7 | Content-Type: text/plain; charset=us-ascii 8 | Content-Disposition: inline 9 | 10 | testing gosendmail 11 | 12 | -------------------------------------------------------------------------------- /lib/transform.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "log" 9 | "net/mail" 10 | "os/exec" 11 | "strings" 12 | "time" 13 | 14 | dkim "github.com/willscott/go-dkim" 15 | ) 16 | 17 | // RemoveHeader strips a single header from a byte array representing a full email 18 | // message. 19 | func RemoveHeader(msg *[]byte, header string) { 20 | // line endings. 21 | if !bytes.Contains(*msg, []byte{13, 10, 13, 10}) { 22 | // \n -> \r\n 23 | *msg = bytes.Replace(*msg, []byte{10}, []byte{13, 10}, -1) 24 | } 25 | 26 | startPtr := 0 27 | endPtr := bytes.Index(*msg, []byte{13, 10, 13, 10}) 28 | if endPtr == -1 { 29 | log.Fatal("couldn't locate end of headers.") 30 | } 31 | out := make([]byte, 0, len(*msg)) 32 | for startPtr < endPtr { 33 | nextPtr := bytes.Index((*msg)[startPtr:], []byte{13, 10}) + 2 34 | // headers keep going until a line that doesn't start with space/tab 35 | for (*msg)[startPtr+nextPtr] == byte(' ') || (*msg)[startPtr+nextPtr] == byte(' ') { 36 | nextPtr = nextPtr + bytes.Index((*msg)[startPtr+nextPtr:], []byte{13, 10}) + 2 37 | } 38 | 39 | headerNameEnd := bytes.Index((*msg)[startPtr:startPtr+nextPtr], []byte{byte(':')}) 40 | headerStr := string((*msg)[startPtr : startPtr+headerNameEnd]) 41 | if !strings.EqualFold(headerStr, header) { 42 | out = append(out, (*msg)[startPtr:startPtr+nextPtr]...) 43 | } 44 | startPtr += nextPtr 45 | } 46 | out = append(out, (*msg)[endPtr:]...) 47 | *msg = out 48 | } 49 | 50 | // SanitizeMessage takes a byte buffer of an Email message, along with configuration 51 | // for the sending domain, and uses these to transform the message into one that is 52 | // more privacy preserving - in particular by quantizing identifying dates and 53 | // message IDs. The byte buffer of the message is modified in-place. 54 | func SanitizeMessage(parsed ParsedMessage, cfg *Config) error { 55 | // line endings. 56 | if !bytes.Contains(*parsed.Bytes, []byte{13, 10, 13, 10}) { 57 | // \n -> \r\n 58 | *parsed.Bytes = bytes.Replace(*parsed.Bytes, []byte{10}, []byte{13, 10}, -1) 59 | } 60 | 61 | // Remove potentially-revealing headers. 62 | removedHeaders := []string{"Date", "Message-ID", "BCC", "X-Mailer"} 63 | for _, h := range removedHeaders { 64 | RemoveHeader(parsed.Bytes, h) 65 | } 66 | 67 | // set date 68 | header := "Date: " + time.Now().Truncate(15*time.Minute).UTC().Format(time.RFC1123Z) + "\r\n" 69 | *parsed.Bytes = append([]byte(header), *parsed.Bytes...) 70 | 71 | // set message id 72 | header = "Message-ID: <" + parsed.Hash() + "@" + parsed.SourceDomain + ">\r\n" 73 | *parsed.Bytes = append([]byte(header), *parsed.Bytes...) 74 | 75 | // Reload the parsed Message from the sanitized version. 76 | m, err := mail.ReadMessage(bytes.NewReader(*parsed.Bytes)) 77 | if err != nil { 78 | return err 79 | } 80 | parsed.Message = m 81 | 82 | return nil 83 | } 84 | 85 | // SignMessage takes a message byte buffer, and adds a DKIM signature to it 86 | // based on the configuration of the sending domain. the buffer is modified 87 | // in place. 88 | func SignMessage(parsed ParsedMessage, cfg *Config) error { 89 | // Determine which subset of headers are included in the signature. 90 | recommendedHeaders := []string{ 91 | "from", "sender", "reply-to", "subject", "date", "message-id", "to", "cc", 92 | "mime-version", "content-type", "content-transfer-encoding", "content-id", 93 | "content-description", "resent-date", "resent-from", "resent-sender", "resent-to", 94 | "resent-cc", "resent-message-id", "in-reply-to", "references", "list-id", "list-help", 95 | "list-unsubscribe", "list-subscribe", "list-post", "list-owner", "list-archive"} 96 | recommendedSet := make(map[string]struct{}, len(recommendedHeaders)) 97 | for _, s := range recommendedHeaders { 98 | recommendedSet[s] = struct{}{} 99 | } 100 | filteredHeaders := make([]string, 0) 101 | for h, v := range parsed.Message.Header { 102 | hl := strings.ToLower(h) 103 | if _, ok := recommendedSet[hl]; ok { 104 | for i := 0; i < len(v); i++ { 105 | filteredHeaders = append(filteredHeaders, hl) 106 | } 107 | } 108 | } 109 | 110 | // Load the key for signing. 111 | keycmd := strings.Split(cfg.DkimKeyCmd, " ") 112 | pkey, err := exec.Command(keycmd[0], keycmd[1:]...).Output() 113 | if err != nil { 114 | log.Fatalf("Could not retreive DKIM key: %v\n", err) 115 | } 116 | 117 | // figure out what type of key it is 118 | kb, _ := pem.Decode(pkey) 119 | if kb == nil { 120 | log.Fatal("Could not decode DKIM key") 121 | } 122 | pk, err := x509.ParsePKCS8PrivateKey(kb.Bytes) 123 | if err != nil { 124 | rpk, err := x509.ParsePKCS1PrivateKey(kb.Bytes) 125 | if err == nil { 126 | pk = rpk 127 | } else { 128 | log.Fatalf("Could not parse DKIM key: %v\n", err) 129 | } 130 | } 131 | algo := "rsa-sha256" 132 | if _, ok := pk.(*rsa.PrivateKey); !ok { 133 | algo = "ed25519-sha256" 134 | } 135 | 136 | selector := "default" 137 | if cfg.DkimSelector != "" { 138 | selector = cfg.DkimSelector 139 | } 140 | 141 | // Sign. 142 | options := dkim.NewSigOptions() 143 | options.Algo = algo 144 | options.PrivateKey = pkey 145 | options.Domain = parsed.SourceDomain 146 | options.Selector = selector 147 | options.SignatureExpireIn = 0 148 | options.Headers = filteredHeaders 149 | options.AddSignatureTimestamp = false 150 | options.Canonicalization = "relaxed/relaxed" 151 | 152 | return dkim.Sign(parsed.Bytes, options) 153 | } 154 | --------------------------------------------------------------------------------