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/<release_tag>.
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/<tag>; 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 <decke@bluelife.at>
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
[](https://goreportcard.com/report/github.com/decke/smtprelay)
[](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 = <hostname> 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
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
SYMBOL INDEX (87 symbols across 12 files)
FILE: aliases.go
type AliasMap (line 11) | type AliasMap
function AliasLoadFile (line 17) | func AliasLoadFile(file string) (AliasMap, error) {
function LoadAliases (line 59) | func LoadAliases(filename string) error {
FILE: aliases_test.go
function init (line 11) | func init() {
function TestAliasLoadFile (line 16) | func TestAliasLoadFile(t *testing.T) {
function TestLoadAliases (line 111) | func TestLoadAliases(t *testing.T) {
function TestLoadAliases_EmptyFile (line 144) | func TestLoadAliases_EmptyFile(t *testing.T) {
function TestLoadAliases_UpdatesExistingAliases (line 165) | func TestLoadAliases_UpdatesExistingAliases(t *testing.T) {
function TestAliasLoadFile_MultipleSpaces (line 218) | func TestAliasLoadFile_MultipleSpaces(t *testing.T) {
function TestAliasLoadFile_TabSeparated (line 244) | func TestAliasLoadFile_TabSeparated(t *testing.T) {
function TestAliasLoadFile_DuplicateKeys (line 267) | func TestAliasLoadFile_DuplicateKeys(t *testing.T) {
function TestAliasLoadFile_OnlyWhitespace (line 294) | func TestAliasLoadFile_OnlyWhitespace(t *testing.T) {
FILE: auth.go
type AuthUser (line 16) | type AuthUser struct
function AuthLoadFile (line 22) | func AuthLoadFile(file string) error {
function AuthReady (line 33) | func AuthReady() bool {
function splitstr (line 39) | func splitstr(s string, sep rune) []string {
function parseLine (line 43) | func parseLine(line string) *AuthUser {
function AuthFetch (line 63) | func AuthFetch(username string) (*AuthUser, error) {
function AuthCheckPassword (line 91) | func AuthCheckPassword(username string, secret string) error {
FILE: auth_test.go
function stringsEqual (line 7) | func stringsEqual(a, b []string) bool {
function TestParseLine (line 19) | func TestParseLine(t *testing.T) {
FILE: config.go
function localAuthRequired (line 68) | func localAuthRequired() bool {
function remoteCertAndKeyReadable (line 72) | func remoteCertAndKeyReadable() bool {
function setupAliases (line 102) | func setupAliases() {
function setupAllowedNetworks (line 115) | func setupAllowedNetworks() {
function setupAllowedPatterns (line 138) | func setupAllowedPatterns() {
function setupRemotes (line 162) | func setupRemotes() {
type protoAddr (line 182) | type protoAddr struct
function splitProto (line 187) | func splitProto(s string) protoAddr {
function setupListeners (line 200) | func setupListeners() {
function setupTimeouts (line 215) | func setupTimeouts() {
function ConfigLoad (line 258) | func ConfigLoad() {
function IniParser (line 313) | func IniParser(r io.Reader, set func(name, value string) error) error {
FILE: config_test.go
function TestSplitProto (line 7) | func TestSplitProto(t *testing.T) {
FILE: logger.go
function setupLogger (line 19) | func setupLogger() {
function closeLogger (line 81) | func closeLogger() {
FILE: main.go
function connectionChecker (line 20) | func connectionChecker(peer smtpd.Peer) error {
function addrAllowed (line 41) | func addrAllowed(addr string, allowedAddrs []string) bool {
function senderChecker (line 84) | func senderChecker(peer smtpd.Peer, addr string) error {
function recipientChecker (line 126) | func recipientChecker(peer smtpd.Peer, addr string) error {
function authChecker (line 144) | func authChecker(peer smtpd.Peer, username string, password string) error {
function mailHandler (line 157) | func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
function generateUUID (line 269) | func generateUUID() string {
function getTLSConfig (line 283) | func getTLSConfig() *tls.Config {
function watchAliasFile (line 303) | func watchAliasFile() {
function main (line 368) | func main() {
function handleSignals (line 484) | func handleSignals() {
FILE: main_test.go
function TestAddrAllowedNoDomain (line 7) | func TestAddrAllowedNoDomain(t *testing.T) {
function TestAddrAllowedSingle (line 14) | func TestAddrAllowedSingle(t *testing.T) {
function TestAddrAllowedDifferentCase (line 25) | func TestAddrAllowedDifferentCase(t *testing.T) {
function TestAddrAllowedLocal (line 40) | func TestAddrAllowedLocal(t *testing.T) {
function TestAddrAllowedMulti (line 51) | func TestAddrAllowedMulti(t *testing.T) {
function TestAddrAllowedSingleDomain (line 64) | func TestAddrAllowedSingleDomain(t *testing.T) {
function TestAddrAllowedMixed (line 74) | func TestAddrAllowedMixed(t *testing.T) {
FILE: remotes.go
type Remote (line 9) | type Remote struct
function ParseRemote (line 30) | func ParseRemote(remoteURL string) (*Remote, error) {
FILE: remotes_test.go
function AssertRemoteUrlEquals (line 10) | func AssertRemoteUrlEquals(t *testing.T, expected *Remote, remotUrl stri...
function TestValidRemoteUrls (line 28) | func TestValidRemoteUrls(t *testing.T) {
function TestMissingScheme (line 110) | func TestMissingScheme(t *testing.T) {
FILE: smtp.go
type Client (line 33) | type Client struct
method Close (line 78) | func (c *Client) Close() error {
method hello (line 83) | func (c *Client) hello() error {
method Hello (line 99) | func (c *Client) Hello(localName string) error {
method cmd (line 111) | func (c *Client) cmd(expectCode int, format string, args ...any) (int,...
method helo (line 124) | func (c *Client) helo() error {
method ehlo (line 132) | func (c *Client) ehlo() error {
method StartTLS (line 155) | func (c *Client) StartTLS(config *tls.Config) error {
method TLSConnectionState (line 172) | func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok b...
method Verify (line 184) | func (c *Client) Verify(addr string) error {
method Auth (line 198) | func (c *Client) Auth(a smtp.Auth) error {
method Mail (line 246) | func (c *Client) Mail(from string) error {
method Rcpt (line 269) | func (c *Client) Rcpt(to string) error {
method Data (line 292) | func (c *Client) Data() (io.WriteCloser, error) {
method Extension (line 430) | func (c *Client) Extension(ext string) (bool, string) {
method Reset (line 444) | func (c *Client) Reset() error {
method Noop (line 454) | func (c *Client) Noop() error {
method Quit (line 463) | func (c *Client) Quit() error {
function Dial (line 54) | func Dial(addr string) (*Client, error) {
function NewClient (line 65) | func NewClient(conn net.Conn, host string) (*Client, error) {
type dataCloser (line 277) | type dataCloser struct
method Close (line 282) | func (d *dataCloser) Close() error {
function SendMail (line 323) | func SendMail(r *Remote, from string, to []string, msg []byte) error {
function validateLine (line 473) | func validateLine(line string) error {
type loginAuth (line 481) | type loginAuth struct
method Start (line 489) | func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, er...
method Next (line 493) | func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
function LoginAuth (line 485) | func LoginAuth(username, password string) smtp.Auth {
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (88K chars).
[
{
"path": ".github/dependabot.yml",
"chars": 206,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"gomod\"\n directory: \"/\"\n schedule:\n interval: \"daily\"\n\n - package"
},
{
"path": ".github/workflows/codeql-analysis.yml",
"chars": 2491,
"preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
},
{
"path": ".github/workflows/dependency-review.yml",
"chars": 970,
"preview": "# Dependency Review Action\n#\n# This Action will scan dependency manifest files that change as part of a Pull Request,\n# "
},
{
"path": ".github/workflows/go.yml",
"chars": 579,
"preview": "name: Go\non: [push, pull_request]\npermissions:\n contents: read\n\njobs:\n\n build:\n name: Build\n runs-on: ubuntu-lat"
},
{
"path": ".github/workflows/release.yaml",
"chars": 2037,
"preview": "name: Release Go Binaries\n\non:\n release:\n types: [created]\n workflow_dispatch:\n inputs:\n release_tag:\n "
},
{
"path": ".github/workflows/scorecards.yml",
"chars": 3201,
"preview": "# This workflow uses actions that are not certified by GitHub. They are provided\n# by a third-party and are governed by "
},
{
"path": "LICENSE",
"chars": 1095,
"preview": "MIT License\n\nCopyright (c) 2018 Bernhard Froehlich <decke@bluelife.at>\n\nPermission is hereby granted, free of charge, to"
},
{
"path": "README.md",
"chars": 1479,
"preview": "# smtprelay\n\n[](https://goreportcard.com/rep"
},
{
"path": "SECURITY.md",
"chars": 1961,
"preview": "# smtprelay Security Policy\n\nThis document outlines security procedures and general policies for the\nsmtprelay project.\n"
},
{
"path": "aliases.go",
"chars": 1163,
"preview": "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 "
},
{
"path": "aliases_test.go",
"chars": 7653,
"preview": "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"
},
{
"path": "auth.go",
"chars": 1757,
"preview": "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\nt"
},
{
"path": "auth_test.go",
"chars": 1792,
"preview": "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\tf"
},
{
"path": "config.go",
"chars": 9700,
"preview": "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/peterbourgo"
},
{
"path": "config_test.go",
"chars": 816,
"preview": "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 "
},
{
"path": "go.mod",
"chars": 664,
"preview": "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"
},
{
"path": "go.sum",
"chars": 2996,
"preview": "github.com/DeRuina/timberjack v1.4.1 h1:JftM5HN/ITKehAXjtdbGqN5XZIS1biHm7VSjU0Qbtqg=\ngithub.com/DeRuina/timberjack v1.4."
},
{
"path": "logger.go",
"chars": 1713,
"preview": "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"
},
{
"path": "main.go",
"chars": 11010,
"preview": "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\""
},
{
"path": "main_test.go",
"chars": 1975,
"preview": "package main\n\nimport (\n\t\"testing\"\n)\n\nfunc TestAddrAllowedNoDomain(t *testing.T) {\n\tallowedAddrs := []string{\"joe@abc.com"
},
{
"path": "remotes.go",
"chars": 1995,
"preview": "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"
},
{
"path": "remotes_test.go",
"chars": 3374,
"preview": "package main\n\nimport (\n\t\"net/smtp\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc AssertRemoteUrlEquals(t *te"
},
{
"path": "smtp.go",
"chars": 13750,
"preview": "// Copyright 2010 The Go Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license "
},
{
"path": "smtprelay.ini",
"chars": 4662,
"preview": "; smtprelay configuration\n;\n; All config parameters can also be provided as environment\n; variables in uppercase and the"
}
]
About this extraction
This page contains the full source code of the decke/smtprelay GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (77.2 KB), approximately 23.4k tokens, and a symbol index with 87 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.