Repository: decke/smtprelay Branch: master Commit: 639d799cd4da Files: 24 Total size: 77.2 KB Directory structure: gitextract_a7e71j_4/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── go.yml │ ├── release.yaml │ └── scorecards.yml ├── LICENSE ├── README.md ├── SECURITY.md ├── aliases.go ├── aliases_test.go ├── auth.go ├── auth_test.go ├── config.go ├── config_test.go ├── go.mod ├── go.sum ├── logger.go ├── main.go ├── main_test.go ├── remotes.go ├── remotes_test.go ├── smtp.go └── smtprelay.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: pull_request: # The branches below must be a subset of the branches above branches: [master] schedule: - cron: '0 15 * * 5' permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 ================================================ FILE: .github/workflows/dependency-review.yml ================================================ # Dependency Review Action # # This Action will scan dependency manifest files that change as part of a Pull Request, # surfacing known-vulnerable versions of the packages declared or updated in the PR. # Once installed, if the workflow run is marked as required, # PRs introducing known-vulnerable packages will be blocked from merging. # # Source repository: https://github.com/actions/dependency-review-action name: 'Dependency Review' on: [pull_request] permissions: contents: read jobs: dependency-review: runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit - name: 'Checkout Repository' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 ================================================ FILE: .github/workflows/go.yml ================================================ name: Go on: [push, pull_request] permissions: contents: read jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: 'stable' - name: Build run: go build -v . - name: Test run: go test -v . ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release Go Binaries on: release: types: [created] workflow_dispatch: inputs: release_tag: description: 'Tag name to build (v1.3.1)' required: false default: '' # Declare default permissions as read only. permissions: read-all jobs: releases-matrix: name: Release Go Binary runs-on: ubuntu-latest strategy: matrix: goos: [freebsd, linux, windows] goarch: [amd64, arm64] permissions: contents: write packages: write steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit - name: Determine ref to checkout run: | # If manually invoked with a release_tag input, use refs/tags/. if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.release_tag }}" ]; then echo "REF=refs/tags/${{ github.event.inputs.release_tag }}" >> $GITHUB_ENV else # For release events GITHUB_REF is already refs/tags/; otherwise fall back to the incoming ref. echo "REF=${GITHUB_REF}" >> $GITHUB_ENV fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.REF }} - name: Set APP_VERSION env run: | # basename strips refs/... and yields the tag or branch name echo "APP_VERSION=$(basename ${REF})" >> $GITHUB_ENV - name: Set BUILD_TIME env run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV} - uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.55 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} extra_files: LICENSE README.md smtprelay.ini ldflags: -s -w -X "main.appVersion=${{ env.APP_VERSION }}" -X "main.buildTime=${{ env.BUILD_TIME }}" release_tag: ${{ env.APP_VERSION }} ================================================ FILE: .github/workflows/scorecards.yml ================================================ # This workflow uses actions that are not certified by GitHub. They are provided # by a third-party and are governed by separate terms of service, privacy # policy, and support documentation. name: Scorecard supply-chain security on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: # To guarantee Maintained check is occasionally updated. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained schedule: - cron: '20 7 * * 2' push: branches: ["master"] # Declare default permissions as read only. permissions: read-all jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest permissions: # Needed to upload the results to code-scanning dashboard. security-events: write # Needed to publish results and get a badge (see publish_results below). id-token: write contents: read actions: read # To allow GraphQL ListCommits to work issues: read pull-requests: read # To detect SAST tools checks: read steps: - name: Harden Runner uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 with: egress-policy: audit - name: "Checkout code" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # - you want to enable the Branch-Protection check on a *public* repository, or # - you are installing Scorecards on a *private* repository # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. # repo_token: ${{ secrets.SCORECARD_TOKEN }} # Public repositories: # - Publish results to OpenSSF REST API for easy access by consumers # - Allows the repository to include the Scorecard badge. # - See https://github.com/ossf/scorecard-action#publishing-results. # For private repositories: # - `publish_results` will always be set to `false`, regardless # of the value entered here. publish_results: true # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: sarif_file: results.sarif ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Bernhard Froehlich 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # smtprelay [![Go Report Card](https://goreportcard.com/badge/github.com/decke/smtprelay)](https://goreportcard.com/report/github.com/decke/smtprelay) [![OpenSSF Scorecard](https://img.shields.io/ossf-scorecard/github.com/decke/smtprelay?label=openssf%20scorecard&style=flat)](https://scorecard.dev/viewer/?uri=github.com/decke/smtprelay) Simple Golang based SMTP relay/proxy server that accepts mail via SMTP and forwards it directly to another SMTP server. ## Why another SMTP server? Outgoing mails are usually send via SMTP to an MTA (Mail Transfer Agent) which is one of Postfix, Exim, Sendmail or OpenSMTPD on UNIX/Linux in most cases. You really don't want to setup and maintain any of those full blown kitchensinks yourself because they are complex, fragile and hard to configure. My use case is simple. I need to send automatically generated mails from cron via msmtp/sSMTP/dma, mails from various services and network printers via a remote SMTP server without giving away my mail credentials to each device which produces mail. ## Main features * Simple configuration with ini file .env file or environment variables * Supports SMTPS/TLS (465), STARTTLS (587) and unencrypted SMTP (25) * Checks for sender, receiver, client IP * Authentication support with file (LOGIN, PLAIN) * Enforce encryption for authentication * Forwards all mail to a smarthost (any SMTP server) * Small codebase * IPv6 support * Aliases support (dynamic reload when alias file changes) ================================================ FILE: SECURITY.md ================================================ # smtprelay Security Policy This document outlines security procedures and general policies for the smtprelay project. ## Supported Versions The latest release is the only supported release. ## Disclosing a security issue The smtprelay maintainers take all security issues in the project seriously. Thank you for improving the security of the project! We appreciate your dedication to responsible disclosure and will make every effort to acknowledge your contributions. smtprelay leverages GitHub's private vulnerability reporting. To learn more about this feature and how to submit a vulnerability report, review [GitHub's documentation on private reporting](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability). Here are some helpful details to include in your report: - a detailed description of the issue - the steps required to reproduce the issue - versions of the project that may be affected by the issue - if known, any mitigations for the issue A maintainer will acknowledge the report within three (3) business days, and will send a more detailed response within an additional three (3) business days indicating the next steps in handling your report. After the initial reply to your report, the maintainers will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. ## Vulnerability management When the maintainers receive a disclosure report, they will coordinate the fix and release process, which involves the following steps: - confirming the issue - determining affected versions of the project - auditing code to find any potential similar problems - preparing fixes for all releases under maintenance ## Suggesting changes If you have suggestions on how this process could be improved please submit an issue or pull request. ================================================ FILE: aliases.go ================================================ package main import ( "bufio" "fmt" "os" "strings" "sync" ) type AliasMap map[string]string var ( aliasesMutex sync.RWMutex ) func AliasLoadFile(file string) (AliasMap, error) { aliasMap := make(AliasMap) count := 0 log.Info(). Str("file", file). Msg("Loading aliases file") f, err := os.Open(file) if err != nil { log.Fatal(). Str("file", file). Err(err). Msg("cannot load aliases file") } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } parts := strings.Fields(line) if len(parts) >= 2 { aliasMap[parts[0]] = parts[1] count++ } } log.Info(). Str("file", file). Msg(fmt.Sprintf("Loaded %d aliases from file", count)) if err := scanner.Err(); err != nil { log.Fatal(). Str("file", file). Err(err). Msg("cannot load aliases file") } return aliasMap, nil } func LoadAliases(filename string) error { newAliases, err := AliasLoadFile(filename) if err != nil { return err } aliasesMutex.Lock() defer aliasesMutex.Unlock() // Update the aliases map aliasesList = newAliases return nil } ================================================ FILE: aliases_test.go ================================================ package main import ( "io" "os" "testing" "github.com/rs/zerolog" ) func init() { // Initialize logger for tests to prevent nil pointer dereference logger := zerolog.New(io.Discard).With().Timestamp().Logger() log = &logger } func TestAliasLoadFile(t *testing.T) { tests := []struct { name string content string expected AliasMap expectError bool }{ { name: "valid aliases", content: "user1 alias1\nuser2 alias2\nuser3 alias3", expected: AliasMap{ "user1": "alias1", "user2": "alias2", "user3": "alias3", }, expectError: false, }, { name: "empty file", content: "", expected: AliasMap{}, expectError: false, }, { name: "file with empty lines", content: "user1 alias1\n\nuser2 alias2\n\n", expected: AliasMap{ "user1": "alias1", "user2": "alias2", }, expectError: false, }, { name: "file with whitespace", content: " user1 alias1 \n\t user2\talias2\t", expected: AliasMap{ "user1": "alias1", "user2": "alias2", }, expectError: false, }, { name: "extra fields ignored", content: "user1 alias1 extra field\nuser2 alias2", expected: AliasMap{ "user1": "alias1", "user2": "alias2", }, expectError: false, }, { name: "single field line ignored", content: "user1\nuser2 alias2", expected: AliasMap{"user2": "alias2"}, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) if _, err := tmpFile.WriteString(tt.content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() result, err := AliasLoadFile(tmpFile.Name()) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } if len(result) != len(tt.expected) { t.Errorf("expected %d aliases, got %d", len(tt.expected), len(result)) } for key, expectedValue := range tt.expected { if actualValue, exists := result[key]; !exists { t.Errorf("expected key %q not found", key) } else if actualValue != expectedValue { t.Errorf("for key %q: expected %q, got %q", key, expectedValue, actualValue) } } }) } } func TestLoadAliases(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) content := "user1 alias1\nuser2 alias2" if _, err := tmpFile.WriteString(content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() err = LoadAliases(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } aliasesMutex.RLock() defer aliasesMutex.RUnlock() if len(aliasesList) != 2 { t.Errorf("expected 2 aliases, got %d", len(aliasesList)) } if aliasesList["user1"] != "alias1" { t.Errorf("expected user1 -> alias1, got %q", aliasesList["user1"]) } if aliasesList["user2"] != "alias2" { t.Errorf("expected user2 -> alias2, got %q", aliasesList["user2"]) } } func TestLoadAliases_EmptyFile(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) tmpFile.Close() err = LoadAliases(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } aliasesMutex.RLock() defer aliasesMutex.RUnlock() if len(aliasesList) != 0 { t.Errorf("expected 0 aliases, got %d", len(aliasesList)) } } func TestLoadAliases_UpdatesExistingAliases(t *testing.T) { // First load tmpFile1, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile1.Name()) content1 := "user1 alias1\nuser2 alias2" if _, err := tmpFile1.WriteString(content1); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile1.Close() err = LoadAliases(tmpFile1.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } // Second load with different content tmpFile2, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile2.Name()) content2 := "user3 alias3" if _, err := tmpFile2.WriteString(content2); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile2.Close() err = LoadAliases(tmpFile2.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } aliasesMutex.RLock() defer aliasesMutex.RUnlock() if len(aliasesList) != 1 { t.Errorf("expected 1 alias after reload, got %d", len(aliasesList)) } if aliasesList["user3"] != "alias3" { t.Errorf("expected user3 -> alias3, got %q", aliasesList["user3"]) } if _, exists := aliasesList["user1"]; exists { t.Error("expected user1 to be removed after reload") } } func TestAliasLoadFile_MultipleSpaces(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) content := "user1 alias1\nuser2 alias2" if _, err := tmpFile.WriteString(content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() result, err := AliasLoadFile(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } if result["user1"] != "alias1" { t.Errorf("expected user1 -> alias1, got %q", result["user1"]) } if result["user2"] != "alias2" { t.Errorf("expected user2 -> alias2, got %q", result["user2"]) } } func TestAliasLoadFile_TabSeparated(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) content := "user1\talias1\nuser2\t\talias2" if _, err := tmpFile.WriteString(content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() result, err := AliasLoadFile(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } if len(result) != 2 { t.Errorf("expected 2 aliases, got %d", len(result)) } } func TestAliasLoadFile_DuplicateKeys(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) content := "user1 alias1\nuser1 alias2\nuser1 alias3" if _, err := tmpFile.WriteString(content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() result, err := AliasLoadFile(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } if len(result) != 1 { t.Errorf("expected 1 alias (last one wins), got %d", len(result)) } if result["user1"] != "alias3" { t.Errorf("expected user1 -> alias3 (last one), got %q", result["user1"]) } } func TestAliasLoadFile_OnlyWhitespace(t *testing.T) { tmpFile, err := os.CreateTemp("", "aliases-*.txt") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) content := " \n\t\t\n \t \n" if _, err := tmpFile.WriteString(content); err != nil { t.Fatalf("failed to write to temp file: %v", err) } tmpFile.Close() result, err := AliasLoadFile(tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } if len(result) != 0 { t.Errorf("expected 0 aliases, got %d", len(result)) } } ================================================ FILE: auth.go ================================================ package main import ( "bufio" "errors" "os" "strings" "golang.org/x/crypto/bcrypt" ) var ( filename string ) type AuthUser struct { username string passwordHash string allowedAddresses []string } func AuthLoadFile(file string) error { f, err := os.Open(file) if err != nil { return err } f.Close() filename = file return nil } func AuthReady() bool { return (filename != "") } // Split a string and ignore empty results // https://stackoverflow.com/a/46798310/119527 func splitstr(s string, sep rune) []string { return strings.FieldsFunc(s, func(c rune) bool { return c == sep }) } func parseLine(line string) *AuthUser { parts := strings.Fields(line) if len(parts) < 2 || len(parts) > 3 { return nil } user := AuthUser{ username: parts[0], passwordHash: parts[1], allowedAddresses: nil, } if len(parts) >= 3 { user.allowedAddresses = splitstr(parts[2], ',') } return &user } func AuthFetch(username string) (*AuthUser, error) { if !AuthReady() { return nil, errors.New("Authentication file not specified. Call LoadFile() first") } file, err := os.Open(filename) if err != nil { return nil, err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { user := parseLine(scanner.Text()) if user == nil { continue } if strings.ToLower(username) != strings.ToLower(user.username) { continue } return user, nil } return nil, errors.New("User not found") } func AuthCheckPassword(username string, secret string) error { user, err := AuthFetch(username) if err != nil { return err } if bcrypt.CompareHashAndPassword([]byte(user.passwordHash), []byte(secret)) == nil { return nil } return errors.New("Password invalid") } ================================================ FILE: auth_test.go ================================================ package main import ( "testing" ) func stringsEqual(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func TestParseLine(t *testing.T) { var tests = []struct { name string expectFail bool line string username string addrs []string }{ { name: "Empty line", expectFail: true, line: "", }, { name: "Too few fields", expectFail: true, line: "joe", }, { name: "Too many fields", expectFail: true, line: "joe xxx joe@example.com whatsthis", }, { name: "Normal case", line: "joe xxx joe@example.com", username: "joe", addrs: []string{"joe@example.com"}, }, { name: "No allowed addrs given", line: "joe xxx", username: "joe", addrs: []string{}, }, { name: "Trailing comma", line: "joe xxx joe@example.com,", username: "joe", addrs: []string{"joe@example.com"}, }, { name: "Multiple allowed addrs", line: "joe xxx joe@example.com,@foo.example.com", username: "joe", addrs: []string{"joe@example.com", "@foo.example.com"}, }, } for i, test := range tests { t.Run(test.name, func(t *testing.T) { user := parseLine(test.line) if user == nil { if !test.expectFail { t.Errorf("parseLine() returned nil unexpectedly") } return } if user.username != test.username { t.Errorf("Testcase %d: Incorrect username: expected %v, got %v", i, test.username, user.username) } if !stringsEqual(user.allowedAddresses, test.addrs) { t.Errorf("Testcase %d: Incorrect addresses: expected %v, got %v", i, test.addrs, user.allowedAddresses) } }) } } ================================================ FILE: config.go ================================================ package main import ( "bufio" "flag" "fmt" "io" "net" "os" "regexp" "strings" "time" "github.com/peterbourgon/ff/v3" ) var ( appVersion = "unknown" buildTime = "unknown" ) var ( flagset = flag.NewFlagSet("smtprelay", flag.ContinueOnError) // config flags logFile = flagset.String("logfile", "", "Path to logfile") logFormat = flagset.String("log_format", "default", "Log output format") logLevel = flagset.String("log_level", "info", "Minimum log level to output") hostName = flagset.String("hostname", "localhost.localdomain", "Server hostname") welcomeMsg = flagset.String("welcome_msg", "", "Welcome message for SMTP session") listenStr = flagset.String("listen", "127.0.0.1:25 [::1]:25", "Address and port to listen for incoming SMTP") localCert = flagset.String("local_cert", "", "SSL certificate for STARTTLS/TLS") localKey = flagset.String("local_key", "", "SSL private key for STARTTLS/TLS") localForceTLS = flagset.Bool("local_forcetls", false, "Force STARTTLS (needs local_cert and local_key)") readTimeoutStr = flagset.String("read_timeout", "60s", "Socket timeout for read operations") writeTimeoutStr = flagset.String("write_timeout", "60s", "Socket timeout for write operations") dataTimeoutStr = flagset.String("data_timeout", "5m", "Socket timeout for DATA command") maxConnections = flagset.Int("max_connections", 100, "Max concurrent connections, use -1 to disable") maxMessageSize = flagset.Int("max_message_size", 10240000, "Max message size in bytes") maxRecipients = flagset.Int("max_recipients", 100, "Max RCPT TO calls for each envelope") allowedNetsStr = flagset.String("allowed_nets", "127.0.0.0/8 ::1/128", "Networks allowed to send mails") allowedSenderStr = flagset.String("allowed_sender", "", "Regular expression for valid FROM EMail addresses") allowedRecipStr = flagset.String("allowed_recipients", "", "Regular expression for valid TO EMail addresses") allowedUsers = flagset.String("allowed_users", "", "Path to file with valid users/passwords") aliasFile = flagset.String("aliases_file", "", "Path to aliases file") command = flagset.String("command", "", "Path to pipe command") remotesStr = flagset.String("remotes", "", "Outgoing SMTP servers") strictSender = flagset.Bool("strict_sender", false, "Use only SMTP servers with Sender matches to From") remoteCert = flagset.String("remote_certificate", "", "Client SSL certificate for remote STARTTLS/TLS") remoteKey = flagset.String("remote_key", "", "Client SSL private key for remote STARTTLS/TLS") // additional flags _ = flagset.String("config", "", "Path to config file (ini format)") versionInfo = flagset.Bool("version", false, "Show version information") // internal listenAddrs = []protoAddr{} readTimeout time.Duration writeTimeout time.Duration dataTimeout time.Duration allowedNets = []*net.IPNet{} allowedSender *regexp.Regexp allowedRecipients *regexp.Regexp remotes = []*Remote{} aliasesList = AliasMap{} ) func localAuthRequired() bool { return *allowedUsers != "" } func remoteCertAndKeyReadable() bool { certSet := *remoteCert != "" keySet := *remoteKey != "" // Both must be set or both must be unset if certSet != keySet { return false } // If both are set, verify files exist and are accessible if certSet && keySet { if _, err := os.Stat(*remoteCert); err != nil { log.Error(). Str("cert", *remoteCert). Err(err). Msg("cannot access remote client certificate file") return false } if _, err := os.Stat(*remoteKey); err != nil { log.Error(). Str("key", *remoteKey). Err(err). Msg("cannot access remote client key file") return false } } return true } func setupAliases() { if *aliasFile != "" { aliases, err := AliasLoadFile(*aliasFile) if err != nil { log.Fatal(). Str("file", *aliasFile). Err(err). Msg("cannot load aliases file") } aliasesList = aliases } } func setupAllowedNetworks() { for _, netstr := range splitstr(*allowedNetsStr, ' ') { baseIP, allowedNet, err := net.ParseCIDR(netstr) if err != nil { log.Fatal(). Str("netstr", netstr). Err(err). Msg("Invalid CIDR notation in allowed_nets") } // Reject any network specification where any host bits are set, // meaning the address refers to a host and not a network. if !allowedNet.IP.Equal(baseIP) { log.Fatal(). Str("given_net", netstr). Str("proper_net", allowedNet.String()). Msg("Invalid network in allowed_nets (host bits set)") } allowedNets = append(allowedNets, allowedNet) } } func setupAllowedPatterns() { var err error if *allowedSenderStr != "" { allowedSender, err = regexp.Compile(*allowedSenderStr) if err != nil { log.Fatal(). Str("allowed_sender", *allowedSenderStr). Err(err). Msg("allowed_sender pattern invalid") } } if *allowedRecipStr != "" { allowedRecipients, err = regexp.Compile(*allowedRecipStr) if err != nil { log.Fatal(). Str("allowed_recipients", *allowedRecipStr). Err(err). Msg("allowed_recipients pattern invalid") } } } func setupRemotes() { logger := log.With().Str("remotes", *remotesStr).Logger() if *remotesStr != "" { for _, remoteURL := range strings.Split(*remotesStr, " ") { r, err := ParseRemote(remoteURL) if err != nil { logger.Fatal().Msg(fmt.Sprintf("error parsing url: '%s': %v", remoteURL, err)) } if *remoteCert != "" && *remoteKey != "" && (r.Scheme == "smtps" || r.Scheme == "starttls") { r.ClientCertPath = *remoteCert r.ClientKeyPath = *remoteKey } remotes = append(remotes, r) } } } type protoAddr struct { protocol string address string } func splitProto(s string) protoAddr { idx := strings.Index(s, "://") if idx == -1 { return protoAddr{ address: s, } } return protoAddr{ protocol: s[0:idx], address: s[idx+3:], } } func setupListeners() { for _, listenAddr := range strings.Split(*listenStr, " ") { pa := splitProto(listenAddr) if localAuthRequired() && pa.protocol == "" { log.Fatal(). Str("address", pa.address). Msg("Local authentication (via allowed_users file) " + "not allowed with non-TLS listener") } listenAddrs = append(listenAddrs, pa) } } func setupTimeouts() { var err error readTimeout, err = time.ParseDuration(*readTimeoutStr) if err != nil { log.Fatal(). Str("read_timeout", *readTimeoutStr). Err(err). Msg("read_timeout duration string invalid") } if readTimeout.Seconds() < 1 { log.Fatal(). Str("read_timeout", *readTimeoutStr). Msg("read_timeout less than one second") } writeTimeout, err = time.ParseDuration(*writeTimeoutStr) if err != nil { log.Fatal(). Str("write_timeout", *writeTimeoutStr). Err(err). Msg("write_timeout duration string invalid") } if writeTimeout.Seconds() < 1 { log.Fatal(). Str("write_timeout", *writeTimeoutStr). Msg("write_timeout less than one second") } dataTimeout, err = time.ParseDuration(*dataTimeoutStr) if err != nil { log.Fatal(). Str("data_timeout", *dataTimeoutStr). Err(err). Msg("data_timeout duration string invalid") } if dataTimeout.Seconds() < 1 { log.Fatal(). Str("data_timeout", *dataTimeoutStr). Msg("data_timeout less than one second") } } func ConfigLoad() { // use .env file if it exists if _, err := os.Stat(".env"); err == nil { if err := ff.Parse(flagset, os.Args[1:], ff.WithEnvVarPrefix("smtprelay"), ff.WithConfigFile(".env"), ff.WithConfigFileParser(ff.EnvParser), ); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } else { // use env variables and smtprelay.ini file if err := ff.Parse(flagset, os.Args[1:], ff.WithEnvVarPrefix("smtprelay"), ff.WithConfigFileFlag("config"), ff.WithConfigFileParser(IniParser), ); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } // Set up logging as soon as possible setupLogger() if *versionInfo { fmt.Printf("smtprelay/%s (%s)\n", appVersion, buildTime) os.Exit(0) } if *remotesStr == "" && *command == "" { log.Warn().Msg("no remotes or command set; mail will not be forwarded!") } if !remoteCertAndKeyReadable() { log.Fatal(). Str("remote_certificate", *remoteCert). Str("remote_key", *remoteKey). Msg("remote_certificate and remote_key must both be set or both be empty") } setupAllowedNetworks() setupAllowedPatterns() setupAliases() setupRemotes() setupListeners() setupTimeouts() } // IniParser is a parser for config files in classic key/value style format. Each // line is tokenized as a single key/value pair. The first "=" delimited // token in the line is interpreted as the flag name, and all remaining tokens // are interpreted as the value. Any leading hyphens on the flag name are // ignored. func IniParser(r io.Reader, set func(name, value string) error) error { s := bufio.NewScanner(r) for s.Scan() { line := strings.TrimSpace(s.Text()) if line == "" { continue // skip empties } if line[0] == '#' || line[0] == ';' { continue // skip comments } var ( name string value string index = strings.IndexRune(line, '=') ) if index < 0 { name, value = line, "true" // boolean option } else { name, value = strings.TrimSpace(line[:index]), strings.Trim(strings.TrimSpace(line[index+1:]), "\"") } if i := strings.Index(value, " #"); i >= 0 { value = strings.TrimSpace(value[:i]) } if err := set(name, value); err != nil { return err } } return nil } ================================================ FILE: config_test.go ================================================ package main import ( "testing" ) func TestSplitProto(t *testing.T) { var tests = []struct { input string proto string addr string }{ { input: "localhost", proto: "", addr: "localhost", }, { input: "tls://my.local.domain", proto: "tls", addr: "my.local.domain", }, { input: "starttls://my.local.domain", proto: "starttls", addr: "my.local.domain", }, } for i, test := range tests { testName := test.input t.Run(testName, func(t *testing.T) { pa := splitProto(test.input) if pa.protocol != test.proto { t.Errorf("Testcase %d: Incorrect proto: expected %v, got %v", i, test.proto, pa.protocol) } if pa.address != test.addr { t.Errorf("Testcase %d: Incorrect addr: expected %v, got %v", i, test.addr, pa.address) } }) } } ================================================ FILE: go.mod ================================================ module github.com/decke/smtprelay require ( github.com/DeRuina/timberjack v1.4.1 github.com/chrj/smtpd v0.4.0 github.com/fsnotify/fsnotify v1.9.0 github.com/google/uuid v1.6.0 github.com/peterbourgon/ff/v3 v3.4.0 github.com/rs/zerolog v1.35.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.49.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) go 1.25.0 ================================================ FILE: go.sum ================================================ github.com/DeRuina/timberjack v1.4.1 h1:JftM5HN/ITKehAXjtdbGqN5XZIS1biHm7VSjU0Qbtqg= github.com/DeRuina/timberjack v1.4.1/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/chrj/smtpd v0.4.0 h1:DPJY9XxJngESsV1O/GKeydN6c+iuV1dglW/dw0VlxFY= github.com/chrj/smtpd v0.4.0/go.mod h1:zEP61gNDlWp/jdUqBcq/ykIbgOERyRvwfMsRLl3h9gM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: logger.go ================================================ package main import ( "fmt" "io" "os" "strings" "time" "github.com/DeRuina/timberjack" "github.com/rs/zerolog" ) var ( rotator *timberjack.Logger log *zerolog.Logger ) func setupLogger() { zerolog.TimeFieldFormat = time.RFC3339 // Handle logfile var writer io.Writer if *logFile == "" { writer = os.Stderr } else { rotator = &timberjack.Logger{ Filename: *logFile, MaxSize: 10, // megabytes before rotation MaxBackups: 3, MaxAge: 30, // days Compress: true, BackupTimeFormat: "20060102150405", } writer = rotator } // Handle log_format switch *logFormat { case "json": // zerolog default is JSON case "plain": writer = zerolog.ConsoleWriter{ Out: writer, NoColor: true, TimeFormat: "", FormatTimestamp: func(i interface{}) string { return "" // avoid default time }, } case "", "default": writer = zerolog.ConsoleWriter{ Out: writer, NoColor: true, TimeFormat: time.RFC3339, } case "pretty": writer = zerolog.ConsoleWriter{ Out: writer, TimeFormat: time.RFC3339Nano, } default: fmt.Fprintf(os.Stderr, "Invalid log_format: %s\n", *logFormat) os.Exit(1) } l := zerolog.New(writer).With().Timestamp().Logger() log = &l // Handle log_level level, err := zerolog.ParseLevel(strings.ToLower(*logLevel)) if err != nil { level = zerolog.InfoLevel log.Warn().Str("given_level", *logLevel). Msg("could not parse log level, defaulting to 'info'") } zerolog.SetGlobalLevel(level) } // Call this on shutdown if you want to close the rotator and stop timers cleanly func closeLogger() { if rotator != nil { rotator.Close() } } ================================================ FILE: main.go ================================================ package main import ( "bytes" "crypto/tls" "fmt" "net" "net/textproto" "os" "os/exec" "os/signal" "strings" "syscall" "github.com/chrj/smtpd" "github.com/fsnotify/fsnotify" "github.com/google/uuid" ) func connectionChecker(peer smtpd.Peer) error { // This can't panic because we only have TCP listeners peerIP := peer.Addr.(*net.TCPAddr).IP if len(allowedNets) == 0 { // Special case: empty string means allow everything return nil } for _, allowedNet := range allowedNets { if allowedNet.Contains(peerIP) { return nil } } log.Warn(). Str("ip", peerIP.String()). Msg("Connection refused from address outside of allowed_nets") return smtpd.Error{Code: 421, Message: "Denied"} } func addrAllowed(addr string, allowedAddrs []string) bool { if allowedAddrs == nil { // If absent, all addresses are allowed return true } addr = strings.ToLower(addr) // Extract optional domain part domain := "" if idx := strings.LastIndex(addr, "@"); idx != -1 { domain = strings.ToLower(addr[idx+1:]) } // Test each address from allowedUsers file for _, allowedAddr := range allowedAddrs { allowedAddr = strings.ToLower(allowedAddr) // Three cases for allowedAddr format: if idx := strings.Index(allowedAddr, "@"); idx == -1 { // 1. local address (no @) -- must match exactly if allowedAddr == addr { return true } } else { if idx != 0 { // 2. email address (user@domain.com) -- must match exactly if allowedAddr == addr { return true } } else { // 3. domain (@domain.com) -- must match addr domain allowedDomain := allowedAddr[idx+1:] if allowedDomain == domain { return true } } } } return false } func senderChecker(peer smtpd.Peer, addr string) error { // check sender address from auth file if user is authenticated if localAuthRequired() && peer.Username != "" { user, err := AuthFetch(peer.Username) if err != nil { // Shouldn't happen: authChecker already validated username+password log.Warn(). Str("peer", peer.Addr.String()). Str("username", peer.Username). Err(err). Msg("could not fetch auth user") return smtpd.Error{Code: 451, Message: "Bad sender address"} } if !addrAllowed(addr, user.allowedAddresses) { log.Warn(). Str("peer", peer.Addr.String()). Str("username", peer.Username). Str("sender_address", addr). Err(err). Msg("sender address not allowed for authenticated user") return smtpd.Error{Code: 451, Message: "Bad sender address"} } } if allowedSender == nil { // Any sender is permitted return nil } if allowedSender.MatchString(addr) { // Permitted by regex return nil } log.Warn(). Str("sender_address", addr). Str("peer", peer.Addr.String()). Msg("sender address not allowed by allowed_sender pattern") return smtpd.Error{Code: 451, Message: "Bad sender address"} } func recipientChecker(peer smtpd.Peer, addr string) error { if allowedRecipients == nil { // Any recipient is permitted return nil } if allowedRecipients.MatchString(addr) { // Permitted by regex return nil } log.Warn(). Str("peer", peer.Addr.String()). Str("recipient_address", addr). Msg("recipient address not allowed by allowed_recipients pattern") return smtpd.Error{Code: 451, Message: "Bad recipient address"} } func authChecker(peer smtpd.Peer, username string, password string) error { err := AuthCheckPassword(username, password) if err != nil { log.Warn(). Str("peer", peer.Addr.String()). Str("username", username). Err(err). Msg("auth error") return smtpd.Error{Code: 535, Message: "Authentication credentials invalid"} } return nil } func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error { peerIP := "" if addr, ok := peer.Addr.(*net.TCPAddr); ok { peerIP = addr.IP.String() } // Check for aliases aliasesMutex.RLock() for i, recipient := range env.Recipients { if alias, exists := aliasesList[recipient]; exists { env.Recipients[i] = alias log.Info(). Str("original_recipient", recipient). Str("aliased_recipient", alias). Msg("Recipient address aliased") } } aliasesMutex.RUnlock() logger := log.With(). Str("from", env.Sender). Strs("to", env.Recipients). Str("peer", peerIP). Str("uuid", generateUUID()). Logger() var envRemotes []*Remote if *strictSender { for _, remote := range remotes { if remote.Sender == env.Sender { envRemotes = append(envRemotes, remote) } } } else { envRemotes = remotes } if len(envRemotes) == 0 && *command == "" { logger.Warn().Msg("no remote_host or command set; discarding mail") return smtpd.Error{Code: 554, Message: "There are no appropriate remote_host or command"} } env.AddReceivedLine(peer) if *command != "" { cmdLogger := logger.With().Str("command", *command).Logger() var stdout bytes.Buffer var stderr bytes.Buffer environ := os.Environ() environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_FROM", env.Sender)) environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_TO", env.Recipients)) environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_PEER", peerIP)) cmd := exec.Cmd{ Env: environ, Path: *command, } cmd.Stdin = bytes.NewReader(env.Data) cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { cmdLogger.Error().Err(err).Msg(stderr.String()) return smtpd.Error{Code: 554, Message: "External command failed"} } cmdLogger.Info().Msg("pipe command successful: " + stdout.String()) } for _, remote := range envRemotes { logger = logger.With().Str("host", remote.Addr).Logger() logger.Info().Msg("delivering mail from peer using smarthost") err := SendMail( remote, env.Sender, env.Recipients, env.Data, ) if err != nil { var smtpError smtpd.Error switch err := err.(type) { case *textproto.Error: smtpError = smtpd.Error{Code: err.Code, Message: err.Msg} logger.Error(). Int("err_code", err.Code). Str("err_msg", err.Msg). Msg("delivery failed") default: smtpError = smtpd.Error{Code: 421, Message: "Forwarding failed"} logger.Error(). Err(err). Msg("delivery failed") } return smtpError } logger.Debug().Msg("delivery successful") } return nil } func generateUUID() string { uniqueID, err := uuid.NewRandom() if err != nil { log.Error(). Err(err). Msg("could not generate UUIDv4") return "" } return uniqueID.String() } func getTLSConfig() *tls.Config { if *localCert == "" || *localKey == "" { log.Fatal(). Str("cert_file", *localCert). Str("key_file", *localKey). Msg("TLS certificate/key file not defined in config") } cert, err := tls.LoadX509KeyPair(*localCert, *localKey) if err != nil { log.Fatal(). Err(err). Msg("cannot load X509 keypair") } return &tls.Config{ Certificates: []tls.Certificate{cert}, } } func watchAliasFile() { if *aliasFile == "" { return } watcher, err := fsnotify.NewWatcher() if err != nil { log.Error(). Err(err). Msg("failed to create file watcher for alias file") return } go func() { defer watcher.Close() for { select { case event, ok := <-watcher.Events: if !ok { return } if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { log.Info(). Str("file", event.Name). Msg("alias file changed, reloading") err := LoadAliases(*aliasFile) if err != nil { log.Error(). Str("file", *aliasFile). Err(err). Msg("failed to reload alias file") } else { log.Info(). Int("count", len(aliasesList)). Msg("alias file reloaded successfully") } } case err, ok := <-watcher.Errors: if !ok { return } log.Error(). Err(err). Msg("file watcher error") } } }() err = watcher.Add(*aliasFile) if err != nil { log.Error(). Str("file", *aliasFile). Err(err). Msg("failed to watch alias file") } else { log.Info(). Str("file", *aliasFile). Msg("watching alias file for changes") } } func main() { ConfigLoad() log.Debug(). Str("version", appVersion). Msg("starting smtprelay") // Load allowed users file if localAuthRequired() { err := AuthLoadFile(*allowedUsers) if err != nil { log.Fatal(). Str("file", *allowedUsers). Err(err). Msg("cannot load allowed users file") } } // Start watching alias file for changes watchAliasFile() var servers []*smtpd.Server // Create a server for each desired listen address for _, listen := range listenAddrs { logger := log.With().Str("address", listen.address).Logger() server := &smtpd.Server{ Hostname: *hostName, WelcomeMessage: *welcomeMsg, ReadTimeout: readTimeout, WriteTimeout: writeTimeout, DataTimeout: dataTimeout, MaxConnections: *maxConnections, MaxMessageSize: *maxMessageSize, MaxRecipients: *maxRecipients, ConnectionChecker: connectionChecker, SenderChecker: senderChecker, RecipientChecker: recipientChecker, Handler: mailHandler, } if localAuthRequired() { server.Authenticator = authChecker } var lsnr net.Listener var err error switch listen.protocol { case "": logger.Info().Msg("listening on address") lsnr, err = net.Listen("tcp", listen.address) case "starttls": server.TLSConfig = getTLSConfig() server.ForceTLS = *localForceTLS logger.Info().Msg("listening on address (STARTTLS)") lsnr, err = net.Listen("tcp", listen.address) case "tls": server.TLSConfig = getTLSConfig() logger.Info().Msg("listening on address (TLS)") lsnr, err = tls.Listen("tcp", listen.address, server.TLSConfig) default: logger.Fatal(). Str("protocol", listen.protocol). Msg("unknown protocol in listen address") } if err != nil { logger.Fatal(). Err(err). Msg("error starting listener") } servers = append(servers, server) go func() { server.Serve(lsnr) }() } handleSignals() // First close the listeners for _, server := range servers { logger := log.With().Str("address", server.Address().String()).Logger() logger.Debug().Msg("Shutting down server") err := server.Shutdown(false) if err != nil { logger.Warn(). Err(err). Msg("Shutdown failed") } } // Then wait for the clients to exit for _, server := range servers { logger := log.With().Str("address", server.Address().String()).Logger() logger.Debug().Msg("Waiting for server") err := server.Wait() if err != nil { logger.Warn(). Err(err). Msg("Wait failed") } } log.Debug().Msg("done") closeLogger() } func handleSignals() { // Wait for SIGINT, SIGQUIT, or SIGTERM sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) sig := <-sigs log.Info(). Str("signal", sig.String()). Msg("shutting down in response to received signal") } ================================================ FILE: main_test.go ================================================ package main import ( "testing" ) func TestAddrAllowedNoDomain(t *testing.T) { allowedAddrs := []string{"joe@abc.com"} if addrAllowed("bob.com", allowedAddrs) { t.FailNow() } } func TestAddrAllowedSingle(t *testing.T) { allowedAddrs := []string{"joe@abc.com"} if !addrAllowed("joe@abc.com", allowedAddrs) { t.FailNow() } if addrAllowed("bob@abc.com", allowedAddrs) { t.FailNow() } } func TestAddrAllowedDifferentCase(t *testing.T) { allowedAddrs := []string{"joe@abc.com"} testAddrs := []string{ "joe@ABC.com", "Joe@abc.com", "JOE@abc.com", "JOE@ABC.COM", } for _, addr := range testAddrs { if !addrAllowed(addr, allowedAddrs) { t.Errorf("Address %v not allowed, but should be", addr) } } } func TestAddrAllowedLocal(t *testing.T) { allowedAddrs := []string{"joe"} if !addrAllowed("joe", allowedAddrs) { t.FailNow() } if addrAllowed("bob", allowedAddrs) { t.FailNow() } } func TestAddrAllowedMulti(t *testing.T) { allowedAddrs := []string{"joe@abc.com", "bob@def.com"} if !addrAllowed("joe@abc.com", allowedAddrs) { t.FailNow() } if !addrAllowed("bob@def.com", allowedAddrs) { t.FailNow() } if addrAllowed("bob@abc.com", allowedAddrs) { t.FailNow() } } func TestAddrAllowedSingleDomain(t *testing.T) { allowedAddrs := []string{"@abc.com"} if !addrAllowed("joe@abc.com", allowedAddrs) { t.FailNow() } if addrAllowed("joe@def.com", allowedAddrs) { t.FailNow() } } func TestAddrAllowedMixed(t *testing.T) { allowedAddrs := []string{"app", "app@example.com", "@appsrv.example.com"} if !addrAllowed("app", allowedAddrs) { t.FailNow() } if !addrAllowed("app@example.com", allowedAddrs) { t.FailNow() } if addrAllowed("ceo@example.com", allowedAddrs) { t.FailNow() } if !addrAllowed("root@appsrv.example.com", allowedAddrs) { t.FailNow() } if !addrAllowed("dev@appsrv.example.com", allowedAddrs) { t.FailNow() } if addrAllowed("appsrv@example.com", allowedAddrs) { t.FailNow() } } ================================================ FILE: remotes.go ================================================ package main import ( "fmt" "net/smtp" "net/url" ) type Remote struct { SkipVerify bool Auth smtp.Auth Scheme string Hostname string Port string Addr string Sender string ClientCertPath string ClientKeyPath string } // ParseRemote creates a remote from a given url in the following format: // // smtp://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...] // smtps://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...] // starttls://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...] // // Supported Params: // - skipVerify: can be "true" or empty to prevent ssl verification of remote server's certificate. // - auth: can be "login" to trigger "LOGIN" auth instead of "PLAIN" auth func ParseRemote(remoteURL string) (*Remote, error) { u, err := url.Parse(remoteURL) if err != nil { return nil, err } if u.Scheme != "smtp" && u.Scheme != "smtps" && u.Scheme != "starttls" { return nil, fmt.Errorf("'%s' is not a supported relay scheme", u.Scheme) } hostname, port := u.Hostname(), u.Port() if port == "" { switch u.Scheme { case "smtp": port = "25" case "smtps": port = "465" case "starttls": port = "587" } } q := u.Query() r := &Remote{ Scheme: u.Scheme, Hostname: hostname, Port: port, Addr: fmt.Sprintf("%s:%s", hostname, port), } if u.User != nil { pass, _ := u.User.Password() user := u.User.Username() if hasAuth, authVal := q.Has("auth"), q.Get("auth"); hasAuth { if authVal != "login" { return nil, fmt.Errorf("Auth must be login or not present, received '%s'", authVal) } r.Auth = LoginAuth(user, pass) } else { r.Auth = smtp.PlainAuth("", user, pass, u.Hostname()) } } if hasVal, skipVerify := q.Has("skipVerify"), q.Get("skipVerify"); hasVal && skipVerify != "false" { r.SkipVerify = true } if u.Path != "" { r.Sender = u.Path[1:] } return r, nil } ================================================ FILE: remotes_test.go ================================================ package main import ( "net/smtp" "testing" "github.com/stretchr/testify/assert" ) func AssertRemoteUrlEquals(t *testing.T, expected *Remote, remotUrl string) { actual, err := ParseRemote(remotUrl) assert.Nil(t, err) assert.NotNil(t, actual) assert.Equal(t, expected.Scheme, actual.Scheme, "Scheme %s", remotUrl) assert.Equal(t, expected.Addr, actual.Addr, "Addr %s", remotUrl) assert.Equal(t, expected.Hostname, actual.Hostname, "Hostname %s", remotUrl) assert.Equal(t, expected.Port, actual.Port, "Port %s", remotUrl) assert.Equal(t, expected.Sender, actual.Sender, "Sender %s", remotUrl) assert.Equal(t, expected.SkipVerify, actual.SkipVerify, "SkipVerify %s", remotUrl) if expected.Auth != nil || actual.Auth != nil { assert.NotNil(t, expected, "Auth %s", remotUrl) assert.NotNil(t, actual, "Auth %s", remotUrl) assert.IsType(t, expected.Auth, actual.Auth) } } func TestValidRemoteUrls(t *testing.T) { AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtp", SkipVerify: false, Auth: nil, Hostname: "email.com", Port: "25", Addr: "email.com:25", Sender: "", }, "smtp://email.com") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtp", SkipVerify: true, Auth: nil, Hostname: "email.com", Port: "25", Addr: "email.com:25", Sender: "", }, "smtp://email.com?skipVerify") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtp", SkipVerify: false, Auth: smtp.PlainAuth("", "user", "pass", ""), Hostname: "email.com", Port: "25", Addr: "email.com:25", Sender: "", }, "smtp://user:pass@email.com") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtp", SkipVerify: false, Auth: LoginAuth("user", "pass"), Hostname: "email.com", Port: "25", Addr: "email.com:25", Sender: "", }, "smtp://user:pass@email.com?auth=login") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtp", SkipVerify: false, Auth: LoginAuth("user", "pass"), Hostname: "email.com", Port: "25", Addr: "email.com:25", Sender: "sender@website.com", }, "smtp://user:pass@email.com/sender@website.com?auth=login") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtps", SkipVerify: false, Auth: LoginAuth("user", "pass"), Hostname: "email.com", Port: "465", Addr: "email.com:465", Sender: "sender@website.com", }, "smtps://user:pass@email.com/sender@website.com?auth=login") AssertRemoteUrlEquals(t, &Remote{ Scheme: "smtps", SkipVerify: true, Auth: LoginAuth("user", "pass"), Hostname: "email.com", Port: "8425", Addr: "email.com:8425", Sender: "sender@website.com", }, "smtps://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify") AssertRemoteUrlEquals(t, &Remote{ Scheme: "starttls", SkipVerify: true, Auth: LoginAuth("user", "pass"), Hostname: "email.com", Port: "8425", Addr: "email.com:8425", Sender: "sender@website.com", }, "starttls://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify") } func TestMissingScheme(t *testing.T) { _, err := ParseRemote("http://user:pass@email.com:8425/sender@website.com") assert.NotNil(t, err, "Err must be present") assert.Equal(t, err.Error(), "'http' is not a supported relay scheme") } ================================================ FILE: smtp.go ================================================ // Copyright 2010 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321. // It also implements the following extensions: // // 8BITMIME RFC 1652 // AUTH RFC 2554 // STARTTLS RFC 3207 // // Additional extensions may be handled by clients. // // The smtp package is frozen and is not accepting new features. // Some external packages provide more functionality. See: // // https://godoc.org/?q=smtp package main import ( "crypto/tls" "encoding/base64" "errors" "fmt" "io" "net" "net/smtp" "net/textproto" "strings" ) // A Client represents a client connection to an SMTP server. type Client struct { // Text is the textproto.Conn used by the Client. It is exported to allow for // clients to add extensions. Text *textproto.Conn // keep a reference to the connection so it can be used to create a TLS // connection later conn net.Conn // whether the Client is using TLS tls bool serverName string // map of supported extensions ext map[string]string // supported auth mechanisms auth []string localName string // the name to use in HELO/EHLO didHello bool // whether we've said HELO/EHLO helloError error // the error from the hello } // Dial returns a new [Client] connected to an SMTP server at addr. // The addr must include a port, as in "mail.example.com:smtp". func Dial(addr string) (*Client, error) { conn, err := net.Dial("tcp", addr) if err != nil { return nil, err } host, _, _ := net.SplitHostPort(addr) return NewClient(conn, host) } // NewClient returns a new [Client] using an existing connection and host as a // server name to be used when authenticating. func NewClient(conn net.Conn, host string) (*Client, error) { text := textproto.NewConn(conn) _, _, err := text.ReadResponse(220) if err != nil { text.Close() return nil, err } c := &Client{Text: text, conn: conn, serverName: host, localName: *hostName} _, c.tls = conn.(*tls.Conn) return c, nil } // Close closes the connection. func (c *Client) Close() error { return c.Text.Close() } // hello runs a hello exchange if needed. func (c *Client) hello() error { if !c.didHello { c.didHello = true err := c.ehlo() if err != nil { c.helloError = c.helo() } } return c.helloError } // Hello sends a HELO or EHLO to the server as the given host name. // Calling this method is only necessary if the client needs control // over the host name used. The client will introduce itself as "localhost" // automatically otherwise. If Hello is called, it must be called before // any of the other methods. func (c *Client) Hello(localName string) error { if err := validateLine(localName); err != nil { return err } if c.didHello { return errors.New("smtp: Hello called after other methods") } c.localName = localName return c.hello() } // cmd is a convenience function that sends a command and returns the response func (c *Client) cmd(expectCode int, format string, args ...any) (int, string, error) { id, err := c.Text.Cmd(format, args...) if err != nil { return 0, "", err } c.Text.StartResponse(id) defer c.Text.EndResponse(id) code, msg, err := c.Text.ReadResponse(expectCode) return code, msg, err } // helo sends the HELO greeting to the server. It should be used only when the // server does not support ehlo. func (c *Client) helo() error { c.ext = nil _, _, err := c.cmd(250, "HELO %s", c.localName) return err } // ehlo sends the EHLO (extended hello) greeting to the server. It // should be the preferred greeting for servers that support it. func (c *Client) ehlo() error { _, msg, err := c.cmd(250, "EHLO %s", c.localName) if err != nil { return err } ext := make(map[string]string) extList := strings.Split(msg, "\n") if len(extList) > 1 { extList = extList[1:] for _, line := range extList { k, v, _ := strings.Cut(line, " ") ext[k] = v } } if mechs, ok := ext["AUTH"]; ok { c.auth = strings.Split(mechs, " ") } c.ext = ext return err } // StartTLS sends the STARTTLS command and encrypts all further communication. // Only servers that advertise the STARTTLS extension support this function. func (c *Client) StartTLS(config *tls.Config) error { if err := c.hello(); err != nil { return err } _, _, err := c.cmd(220, "STARTTLS") if err != nil { return err } c.conn = tls.Client(c.conn, config) c.Text = textproto.NewConn(c.conn) c.tls = true return c.ehlo() } // TLSConnectionState returns the client's TLS connection state. // The return values are their zero values if [Client.StartTLS] did // not succeed. func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { tc, ok := c.conn.(*tls.Conn) if !ok { return } return tc.ConnectionState(), true } // Verify checks the validity of an email address on the server. // If Verify returns nil, the address is valid. A non-nil return // does not necessarily indicate an invalid address. Many servers // will not verify addresses for security reasons. func (c *Client) Verify(addr string) error { if err := validateLine(addr); err != nil { return err } if err := c.hello(); err != nil { return err } _, _, err := c.cmd(250, "VRFY %s", addr) return err } // Auth authenticates a client using the provided authentication mechanism. // A failed authentication closes the connection. // Only servers that advertise the AUTH extension support this function. func (c *Client) Auth(a smtp.Auth) error { if err := c.hello(); err != nil { return err } encoding := base64.StdEncoding mech, resp, err := a.Start(&smtp.ServerInfo{c.serverName, c.tls, c.auth}) if err != nil { c.Quit() return err } resp64 := make([]byte, encoding.EncodedLen(len(resp))) encoding.Encode(resp64, resp) code, msg64, err := c.cmd(0, "%s", strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))) for err == nil { var msg []byte switch code { case 334: msg, err = encoding.DecodeString(msg64) case 235: // the last message isn't base64 because it isn't a challenge msg = []byte(msg64) default: err = &textproto.Error{Code: code, Msg: msg64} } if err == nil { resp, err = a.Next(msg, code == 334) } if err != nil { // abort the AUTH c.cmd(501, "*") c.Quit() break } if resp == nil { break } resp64 = make([]byte, encoding.EncodedLen(len(resp))) encoding.Encode(resp64, resp) code, msg64, err = c.cmd(0, "%s", resp64) } return err } // Mail issues a MAIL command to the server using the provided email address. // If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME // parameter. If the server supports the SMTPUTF8 extension, Mail adds the // SMTPUTF8 parameter. // This initiates a mail transaction and is followed by one or more [Client.Rcpt] calls. func (c *Client) Mail(from string) error { if err := validateLine(from); err != nil { return err } if err := c.hello(); err != nil { return err } cmdStr := "MAIL FROM:<%s>" if c.ext != nil { if _, ok := c.ext["8BITMIME"]; ok { cmdStr += " BODY=8BITMIME" } if _, ok := c.ext["SMTPUTF8"]; ok { cmdStr += " SMTPUTF8" } } _, _, err := c.cmd(250, cmdStr, from) return err } // Rcpt issues a RCPT command to the server using the provided email address. // A call to Rcpt must be preceded by a call to [Client.Mail] and may be followed by // a [Client.Data] call or another Rcpt call. func (c *Client) Rcpt(to string) error { if err := validateLine(to); err != nil { return err } _, _, err := c.cmd(25, "RCPT TO:<%s>", to) return err } type dataCloser struct { c *Client io.WriteCloser } func (d *dataCloser) Close() error { d.WriteCloser.Close() _, _, err := d.c.Text.ReadResponse(250) return err } // Data issues a DATA command to the server and returns a writer that // can be used to write the mail headers and body. The caller should // close the writer before calling any more methods on c. A call to // Data must be preceded by one or more calls to [Client.Rcpt]. func (c *Client) Data() (io.WriteCloser, error) { _, _, err := c.cmd(354, "DATA") if err != nil { return nil, err } return &dataCloser{c, c.Text.DotWriter()}, nil } var testHookStartTLS func(*tls.Config) // nil, except for tests // SendMail connects to the server at addr with TLS when port 465 or // smtps is specified or unencrypted otherwise and switches to TLS if // possible, authenticates with the optional mechanism a if possible, // and then sends an email from address from, to addresses to, with // message msg. // The addr must include a port, as in "mail.example.com:smtp". // // The addresses in the to parameter are the SMTP RCPT addresses. // // The msg parameter should be an RFC 822-style email with headers // first, a blank line, and then the message body. The lines of msg // should be CRLF terminated. The msg headers should usually include // fields such as "From", "To", "Subject", and "Cc". Sending "Bcc" // messages is accomplished by including an email address in the to // parameter but not including it in the msg headers. // // The SendMail function and the net/smtp package are low-level // mechanisms and provide no support for DKIM signing, MIME // attachments (see the mime/multipart package), or other mail // functionality. Higher-level packages exist outside of the standard // library. func SendMail(r *Remote, from string, to []string, msg []byte) error { if r.Sender != "" { from = r.Sender } if err := validateLine(from); err != nil { return err } for _, recp := range to { if err := validateLine(recp); err != nil { return err } } var c *Client var err error if r.Scheme == "smtps" { config := &tls.Config{ ServerName: r.Hostname, InsecureSkipVerify: r.SkipVerify, } // Load client certificate on-demand, just before connection if r.ClientCertPath != "" && r.ClientKeyPath != "" { cert, err := tls.LoadX509KeyPair(r.ClientCertPath, r.ClientKeyPath) if err != nil { return err } config.Certificates = []tls.Certificate{cert} } conn, err := tls.Dial("tcp", r.Addr, config) if err != nil { return err } defer conn.Close() c, err = NewClient(conn, r.Hostname) if err != nil { return err } if err = c.hello(); err != nil { return err } } else { c, err = Dial(r.Addr) if err != nil { return err } defer c.Close() if err = c.hello(); err != nil { return err } if ok, _ := c.Extension("STARTTLS"); ok { config := &tls.Config{ ServerName: c.serverName, InsecureSkipVerify: r.SkipVerify, } // Load client certificate on-demand, just before use if r.ClientCertPath != "" && r.ClientKeyPath != "" { cert, err := tls.LoadX509KeyPair(r.ClientCertPath, r.ClientKeyPath) if err != nil { return err } config.Certificates = []tls.Certificate{cert} } if testHookStartTLS != nil { testHookStartTLS(config) } if err = c.StartTLS(config); err != nil { return err } } else if r.Scheme == "starttls" { return errors.New("starttls: server does not support extension, check remote scheme") } } if r.Auth != nil && c.ext != nil { if _, ok := c.ext["AUTH"]; !ok { return errors.New("smtp: server doesn't support AUTH") } if err = c.Auth(r.Auth); err != nil { return err } } if err = c.Mail(from); err != nil { return err } for _, addr := range to { if err = c.Rcpt(addr); err != nil { return err } } w, err := c.Data() if err != nil { return err } _, err = w.Write(msg) if err != nil { return err } err = w.Close() if err != nil { return err } return c.Quit() } // Extension reports whether an extension is support by the server. // The extension name is case-insensitive. If the extension is supported, // Extension also returns a string that contains any parameters the // server specifies for the extension. func (c *Client) Extension(ext string) (bool, string) { if err := c.hello(); err != nil { return false, "" } if c.ext == nil { return false, "" } ext = strings.ToUpper(ext) param, ok := c.ext[ext] return ok, param } // Reset sends the RSET command to the server, aborting the current mail // transaction. func (c *Client) Reset() error { if err := c.hello(); err != nil { return err } _, _, err := c.cmd(250, "RSET") return err } // Noop sends the NOOP command to the server. It does nothing but check // that the connection to the server is okay. func (c *Client) Noop() error { if err := c.hello(); err != nil { return err } _, _, err := c.cmd(250, "NOOP") return err } // Quit sends the QUIT command and closes the connection to the server. func (c *Client) Quit() error { c.hello() // ignore error; we're quitting anyhow _, _, err := c.cmd(221, "QUIT") if err != nil { return err } return c.Text.Close() } // validateLine checks to see if a line has CR or LF as per RFC 5321. func validateLine(line string) error { if strings.ContainsAny(line, "\n\r") { return errors.New("smtp: A line must not contain CR or LF") } return nil } // LOGIN authentication type loginAuth struct { username, password string } func LoginAuth(username, password string) smtp.Auth { return &loginAuth{username, password} } func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", []byte{}, nil } func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { if more { switch string(fromServer) { case "Username:": return []byte(a.username), nil case "Password:": return []byte(a.password), nil default: return nil, errors.New("Unknown fromServer") } } return nil, nil } ================================================ FILE: smtprelay.ini ================================================ ; smtprelay configuration ; ; All config parameters can also be provided as environment ; variables in uppercase and the prefix "SMTPRELAY_". ; (eg. SMTPRELAY_LOGFILE, SMTPRELAY_LOG_FORMAT) ; Logfile (blank/default is stderr) ;logfile = ; Log format: default, plain (no timestamp), json ;log_format = default ; Log level: panic, fatal, error, warn, info, debug, trace ;log_level = info ; path to alias file ; alias file format (separated by space): ; fake@email.tld real@email.tld ;aliases_file = aliases.txt ; Hostname for this SMTP server ;hostname = localhost.localdomain ; Welcome message for clients ;welcome_msg = ESMTP ready. ; Listen on the following addresses for incoming ; unencrypted connections. ;listen = 127.0.0.1:25 [::1]:25 ; STARTTLS and TLS are also supported but need a ; SSL certificate and key. ;listen = tls://127.0.0.1:465 tls://[::1]:465 ;listen = starttls://127.0.0.1:587 starttls://[::1]:587 ;local_cert = smtpd.pem ;local_key = smtpd.key ; Enforce encrypted connection on STARTTLS ports before ; accepting mails from client. ;local_forcetls = false ; Only use remotes where FROM EMail address in received ; EMail matches remote_sender. ;strict_sender = false ; Socket timeout for read operations ; Duration string as sequence of decimal numbers, ; each with optional fraction and a unit suffix. ; Valid time units are "ns", "us", "ms", "s", "m", "h". ;read_timeout = 60s ; Socket timeout for write operations ; Duration string as sequence of decimal numbers, ; each with optional fraction and a unit suffix. ; Valid time units are "ns", "us", "ms", "s", "m", "h". ;write_timeout = 60s ; Socket timeout for DATA command ; Duration string as sequence of decimal numbers, ; each with optional fraction and a unit suffix. ; Valid time units are "ns", "us", "ms", "s", "m", "h". ;data_timeout = 5m ; Max concurrent connections, use -1 to disable ;max_connections = 100 ; Max message size in bytes ;max_message_size = 10240000 ; Max RCPT TO calls for each envelope ;max_recipients = 100 ; Networks that are allowed to send mails to us ; Defaults to localhost. If set to "", then any address is allowed. ;allowed_nets = 127.0.0.0/8 ::1/128 ; Regular expression for valid FROM EMail addresses ; If set to "", then any sender is permitted. ; Example: ^(.*)@localhost.localdomain$ ;allowed_sender = ; Regular expression for valid TO EMail addresses ; If set to "", then any recipient is permitted. ; Example: ^(.*)@localhost.localdomain$ ;allowed_recipients = ; File which contains username and password used for ; authentication before they can send mail. ; File format: username bcrypt-hash [email[,email[,...]]] ; username: The SMTP auth username ; bcrypt-hash: The bcrypt hash of the pasword ; email: Comma-separated list of allowed "from" addresses: ; - If omitted, user can send from any address ; - If @domain.com is given, user can send from any address @domain.com ; - Otherwise, email address must match exactly (case-insensitive) ; E.g. "app@example.com,@appsrv.example.com" ;allowed_users = ; Relay all mails to this SMTP servers. ; If not set, mails are discarded. ; ; Format: ; protocol://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...] ; ; protocol: smtp (unencrypted), smtps (TLS), starttls (STARTTLS) ; user: Username for authentication ; password: Password for authentication ; remote_sender: Email address to use as FROM ; params: ; skipVerify: "true" or empty to prevent ssl verification of remote server's certificate ; auth: "login" to use LOGIN authentication ; GMail ;remotes = starttls://user:pass@smtp.gmail.com:587 ; Mailgun.org ;remotes = starttls://user:pass@smtp.mailgun.org:587 ; Mailjet.com ;remotes = starttls://user:pass@in-v3.mailjet.com:587 ; Exchange Online (O365) SMTP relay ; (Change netloc to your own Exchange MX endpoint) ; remotes = starttls://contoso-com.mail.protection.outlook.com:25 ; Ignore remote host certificates ;remotes = starttls://user:pass@server:587?skipVerify ; Login Authentication method on outgoing SMTP server ;remotes = smtp://user:pass@server:2525?auth=login ; Sender e-mail address on outgoing SMTP server ;remotes = smtp://user:pass@server:2525/overridden@email.com?auth=login ; Multiple remotes, space delimited ;remotes = smtp://127.0.0.1:1025 starttls://user:pass@smtp.mailgun.org:587 ; Client SSL certificate for remote STARTTLS/TLS ; remote_certificate = /path/to/certificate-chain.pem ; Client SSL private key for remote STARTTLS/TLS ; remote_key = /path/to/private-key.pem ; Pipe messages to external command ;command = /usr/local/bin/script