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