initial commit
This commit is contained in:
commit
f28bfd0ace
7 changed files with 358 additions and 0 deletions
4
.envrc
Normal file
4
.envrc
Normal 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
12
cmd/inbound/inbound.go
Normal 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
61
flake.lock
Normal 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
52
flake.nix
Normal 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
17
go.mod
Normal 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
35
go.sum
Normal 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
177
server/server.go
Normal 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,
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue