From f28bfd0acebadaef5b7e3b3964dae42b2f0d6a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20C=20McCord?= Date: Fri, 12 May 2023 21:14:58 -0400 Subject: [PATCH] initial commit --- .envrc | 4 + cmd/inbound/inbound.go | 12 +++ flake.lock | 61 ++++++++++++++ flake.nix | 52 ++++++++++++ go.mod | 17 ++++ go.sum | 35 ++++++++ server/server.go | 177 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 358 insertions(+) create mode 100644 .envrc create mode 100644 cmd/inbound/inbound.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 server/server.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1305de8 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.2.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.0/direnvrc" "sha256-5EwyKnkJNQeXrRkYbwwRBcXbibosCJqyIUuz9Xq+LRc=" +fi +use flake diff --git a/cmd/inbound/inbound.go b/cmd/inbound/inbound.go new file mode 100644 index 0000000..fd579c0 --- /dev/null +++ b/cmd/inbound/inbound.go @@ -0,0 +1,12 @@ +package main + +import "flag" + +var listenPort int + +func init() { + flag.IntVar(&listenPort, "p", 2525, "port on which to listen for incoming emails") +} + +func main() { +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b74b6e1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1681202837, + "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1683627095, + "narHash": "sha256-8u9SejRpL2TrMuHBdhYh4FKc1OGPDLyWTpIbNTtoHsA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "a08e061a4ee8329747d54ddf1566d34c55c895eb", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-22.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..166e5da --- /dev/null +++ b/flake.nix @@ -0,0 +1,52 @@ +{ + description = "tkcli devshell"; + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-22.11"; + #nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; # localstack is broken right now (2023-01-25) in unstable, due to a missing dependency + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }@inputs: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + gci = pkgs.buildGoModule rec { + name = "gci"; + src = pkgs.fetchFromGitHub { + owner = "daixiang0"; + repo = "gci"; + rev = "v0.10.1"; + sha256 = "sha256-/YR61lovuYw+GEeXIgvyPbesz2epmQVmSLWjWwKT4Ag="; + }; + + # Switch to fake vendor sha for upgrades: + #vendorSha256 = pkgs.lib.fakeSha256; + vendorSha256 = "sha256-g7htGfU6C2rzfu8hAn6SGr0ZRwB8ZzSf9CgHYmdupE8="; + }; + + cclint = pkgs.writeScriptBin "lint" '' + #!/bin/sh + pushd $(git rev-parse --show-toplevel)/ + ${pkgs.go}/bin/go mod tidy + ${pkgs.gofumpt}/bin/gofumpt -w *.go ./cmd/* + ${gci}/bin/gci write --skip-generated -s standard -s default -s "Prefix(git.cycore.io/cycore)" . + ${pkgs.golangci-lint}/bin/golangci-lint run ./... + ${pkgs.go}/bin/go test -v ./... + ''; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + bashInteractive + envsubst + gci + gofumpt + golangci-lint + go + go-tools + cclint + ]; + }; + }); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d2cd6f --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.cycore.io/cycore/mail + +go 1.20 + +require ( + github.com/emersion/go-imap v1.2.1 // indirect + github.com/emersion/go-message v0.16.0 // indirect + github.com/emersion/go-milter v0.3.3 // indirect + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/emersion/go-smtp v0.16.0 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b079f3 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4= +github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ= +github.com/emersion/go-milter v0.3.3 h1:DiP9Xmw2FqEuosNCd01XPDBb1K3OziNmt7BG2ddFlgs= +github.com/emersion/go-milter v0.3.3/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8= +github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..09b7675 --- /dev/null +++ b/server/server.go @@ -0,0 +1,177 @@ +package server + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-milter" + "github.com/emersion/go-smtp" + "go.uber.org/zap" + "golang.org/x/time/rate" +) + +const ConnectionTimeout = 3 * time.Minute + +// ErrRateLimit indicates that the message is rejected due to a rate limit. +var ErrRateLimit = &smtp.SMTPError{ + Code: 450, + EnhancedCode: [3]int{4, 2, 0}, + Message: "too many requests", +} + +// ErrBadGateway indicates a downstream system failure. +var ErrBadGateway = &smtp.SMTPError{ + Code: 451, + EnhancedCode: [3]int{4, 4, 3}, + Message: "bad gateway", +} + +// Backend provides a backend implementation of an SMTP server. +type Backend struct { + AllowedDomains []string + + Log *zap.Logger + + RootContext context.Context + + RSpam *milter.Client + + OverallLimiter *rate.Limiter +} + +// NewSession implements smtp.Backend +func (b *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { + ctx, cancel := context.WithTimeout(b.RootContext, ConnectionTimeout) + defer cancel() + + if err := b.OverallLimiter.Wait(ctx); err != nil { + return nil, ErrRateLimit + } + + rs, err := b.RSpam.Session() + if err != nil { + return nil, ErrBadGateway + } + + return &Session{ + b: b, + conn: conn, + rspam: rs, + }, nil +} + +// Session provides an implementation of an SMTP session. +type Session struct { + b *Backend + conn *smtp.Conn + rspam *milter.ClientSession +} + +// Discard currently processed message. +func (session *Session) Reset() { + panic("not implemented") // TODO: Implement +} + +// Free all resources associated with session. +func (session *Session) Logout() error { + panic("not implemented") // TODO: Implement +} + +// Authenticate the user using SASL PLAIN. +func (session *Session) AuthPlain(username string, password string) error { + return smtp.ErrAuthUnsupported +} + +// Set return path for currently processed message. +func (session *Session) Mail(from string, opts *smtp.MailOptions) error { + action, err := session.rspam.Mail(from, nil) + if err != nil { + return fmt.Errorf("filter failure: %w", err) + } + + return parseAction(action) +} + +// Add recipient for currently processed message. +func (session *Session) Rcpt(to string) error { + if err := session.validateDomain(to); err != nil { + return &smtp.SMTPError{ + Code: 550, + EnhancedCode: [3]int{5, 1, 2}, + Message: err.Error(), + } + } + + action, err := session.rspam.Rcpt(to, nil) + if err != nil { + return fmt.Errorf("filter failure: %w", err) + } + + return parseAction(action) +} + +// Set currently processed message contents and send it. +// +// r must be consumed before Data returns. +func (session *Session) Data(r io.Reader) error { + // TODO: handle the modifyAction below, whatever it is. + _, action, err := session.rspam.BodyReadFrom(r) + if err != nil { + return fmt.Errorf("filter failure: %w", err) + } + + return parseAction(action) +} + +func (session *Session) validateDomain(to string) error { + toAddr, err := mail.ParseAddress(to) + if err != nil { + return fmt.Errorf("failed to parse address: %w", err) + } + + pieces := strings.Split(toAddr.Address, "@") + if len(pieces) != 2 { + return fmt.Errorf("invalid address") + } + + for _, d := range session.b.AllowedDomains { + if strings.Contains(pieces[2], d) { + return nil + } + } + + return fmt.Errorf("invalid domain") +} + +func parseAction(action *milter.Action) error { + switch action.Code { + case milter.ActAccept: + return nil + case milter.ActContinue: + return nil + case milter.ActDiscard: + return milterReject(action) + case milter.ActReject: + return milterReject(action) + case milter.ActTempFail: + return milterReject(action) + case milter.ActReplyCode: + return milterReject(action) + case milter.ActSkip: + return milterReject(action) + } + + return fmt.Errorf("unhandled action code %q", action.Code) +} + +func milterReject(action *milter.Action) error { + return &smtp.SMTPError{ + Code: action.SMTPCode, + EnhancedCode: [3]int{4, 2, 0}, + Message: action.SMTPText, + } +}