initial commit

This commit is contained in:
Seán C McCord 2023-05-12 21:14:58 -04:00
commit f28bfd0ace
No known key found for this signature in database
GPG key ID: 0BE02ACD7471B0E8
7 changed files with 358 additions and 0 deletions

4
.envrc Normal file
View file

@ -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

12
cmd/inbound/inbound.go Normal file
View file

@ -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() {
}

61
flake.lock Normal file
View file

@ -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
}

52
flake.nix Normal file
View file

@ -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
];
};
});
}

17
go.mod Normal file
View file

@ -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
)

35
go.sum Normal file
View file

@ -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=

177
server/server.go Normal file
View file

@ -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,
}
}