├── .github └── workflows │ └── release.yml ├── README.md ├── go.mod ├── main.go └── go.sum /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - uses: actions/setup-go@v2 14 | with: 15 | go-version: 1.19 16 | cache: true 17 | 18 | - uses: ko-build/setup-ko@v0.6 19 | 20 | - run: ko build --bare --sbom none . 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ses-sidecar 2 | 3 | ## Usage 4 | 5 | ``` 6 | docker run -it \ 7 | -p 1025:1025 \ 8 | -e ADDR=0.0.0.0:1025 \ 9 | -e AWS_ACCESS_KEY_ID \ 10 | -e AWS_SECRET_ACCESS_KEY \ 11 | -e AWS_SESSION_TOKEN \ 12 | -e AWS_REGION \ 13 | ghcr.io/aidansteele/ses-sidecar:latest 14 | ``` 15 | 16 | This will start an SMTP server listening on port 1025 that uses the AWS SES 17 | SendRawEmail API to deliver email. In practice, you wouldn't pass credentials 18 | like this example, you would associate an IAM role with the container via your 19 | orchestration system, e.g. an ECS task IAM role or EKS IRSA service account role. 20 | That role needs `ses:SendRawEmail` permission. 21 | 22 | This is a proof-of-concept, but it works and can be deployed as a sidecar to 23 | your application. It exists because (as of the time of writing) the SES SMTP 24 | service doesn't work with temporary credentials, which are a security best-practice. 25 | File an issue if you have any problems / feature requests. 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aidansteele/ses-sidecar 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.17.4 // indirect 7 | github.com/aws/aws-sdk-go-v2/config v1.18.12 // indirect 8 | github.com/aws/aws-sdk-go-v2/credentials v1.13.12 // indirect 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 // indirect 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 // indirect 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 // indirect 12 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 // indirect 13 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 // indirect 14 | github.com/aws/aws-sdk-go-v2/service/ses v1.15.1 // indirect 15 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 // indirect 16 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 // indirect 17 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect 18 | github.com/aws/smithy-go v1.13.5 // indirect 19 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect 20 | github.com/emersion/go-smtp v0.16.0 // indirect 21 | github.com/jmespath/go-jmespath v0.4.0 // indirect 22 | golang.org/x/exp v0.0.0-20230206171751-46f607a40771 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/config" 7 | "github.com/aws/aws-sdk-go-v2/service/ses" 8 | "github.com/aws/aws-sdk-go-v2/service/ses/types" 9 | "github.com/emersion/go-smtp" 10 | "golang.org/x/exp/slog" 11 | "io" 12 | "net" 13 | "os" 14 | "time" 15 | ) 16 | 17 | func main() { 18 | ctx := context.Background() 19 | 20 | logger := slog.New(slog.NewJSONHandler(os.Stdout)) 21 | slog.SetDefault(logger) 22 | 23 | cfg, err := config.LoadDefaultConfig(ctx) 24 | if err != nil { 25 | panic(fmt.Sprintf("%+v", err)) 26 | } 27 | 28 | bkd := &Backend{ 29 | logger: logger, 30 | ses: ses.NewFromConfig(cfg), 31 | baseCtx: ctx, 32 | } 33 | 34 | addr := os.Getenv("ADDR") 35 | if addr == "" { 36 | addr = "127.0.0.1:1025" 37 | } 38 | 39 | s := smtp.NewServer(bkd) 40 | s.Addr = addr 41 | s.Domain = "localhost" 42 | s.ReadTimeout = 10 * time.Second 43 | s.WriteTimeout = 10 * time.Second 44 | s.AllowInsecureAuth = true 45 | 46 | logger.Info("Starting server", "addr", s.Addr) 47 | if err := s.ListenAndServe(); err != nil { 48 | logger.Error("starting server", err) 49 | os.Exit(1) 50 | } 51 | } 52 | 53 | type Backend struct { 54 | logger *slog.Logger 55 | ses *ses.Client 56 | baseCtx context.Context 57 | } 58 | 59 | func (bkd *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { 60 | clientIp, clientPort, _ := net.SplitHostPort(conn.Conn().RemoteAddr().String()) 61 | l := bkd.logger.With("clientIp", clientIp, "clientPort", clientPort) 62 | 63 | return &Session{ 64 | logger: l, 65 | baseLogger: l, 66 | ses: bkd.ses, 67 | baseCtx: bkd.baseCtx, 68 | }, nil 69 | } 70 | 71 | // A Session is returned after EHLO. 72 | type Session struct { 73 | logger *slog.Logger 74 | baseLogger *slog.Logger 75 | ses *ses.Client 76 | baseCtx context.Context 77 | 78 | from string 79 | recipients []string 80 | } 81 | 82 | func (s *Session) AuthPlain(username, password string) error { 83 | s.logger = s.logger.With("clientUsername", username) 84 | return nil 85 | } 86 | 87 | func (s *Session) Mail(from string, opts *smtp.MailOptions) error { 88 | s.logger.Debug("MAIL FROM", "from", from) 89 | s.from = from 90 | return nil 91 | } 92 | 93 | func (s *Session) Rcpt(to string) error { 94 | s.recipients = append(s.recipients, to) 95 | s.logger.Debug("RCPT TO", "to", to, "recipients", s.recipients) 96 | return nil 97 | } 98 | 99 | func (s *Session) Data(r io.Reader) error { 100 | ctx := s.baseCtx 101 | l := s.logger.With("from", s.from, "recipients", s.recipients) 102 | 103 | msg, err := io.ReadAll(r) 104 | if err != nil { 105 | l.Error("reading msg", err) 106 | return err 107 | } 108 | 109 | sent, err := s.ses.SendRawEmail(ctx, &ses.SendRawEmailInput{ 110 | Source: &s.from, 111 | Destinations: s.recipients, 112 | RawMessage: &types.RawMessage{Data: msg}, 113 | }) 114 | if err != nil { 115 | l.Error("calling SendRawEmail", err) 116 | return err 117 | } 118 | 119 | messageId := *sent.MessageId 120 | l = l.With("sesMessageId", messageId) 121 | l.Info("Sent email") 122 | 123 | return &smtp.SMTPError{ 124 | Code: 250, 125 | EnhancedCode: smtp.EnhancedCode{2, 0, 0}, 126 | Message: fmt.Sprintf("OK: queued as %s", messageId), 127 | } 128 | } 129 | 130 | func (s *Session) Reset() { 131 | s.logger = s.baseLogger 132 | s.from = "" 133 | s.recipients = nil 134 | } 135 | 136 | func (s *Session) Logout() error { 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY= 2 | github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= 3 | github.com/aws/aws-sdk-go-v2/config v1.18.12 h1:fKs/I4wccmfrNRO9rdrbMO1NgLxct6H9rNMiPdBxHWw= 4 | github.com/aws/aws-sdk-go-v2/config v1.18.12/go.mod h1:J36fOhj1LQBr+O4hJCiT8FwVvieeoSGOtPuvhKlsNu8= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.13.12 h1:Cb+HhuEnV19zHRaYYVglwvdHGMJWbdsyP4oHhw04xws= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.13.12/go.mod h1:37HG2MBroXK3jXfxVGtbM2J48ra2+Ltu+tmwr/jO0KA= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22 h1:3aMfcTmoXtTZnaT86QlVaYh+BRMbvrrmZwIQ5jWqCZQ= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.22/go.mod h1:YGSIJyQ6D6FjKMQh16hVFSIUD54L4F7zTGePqYMYYJU= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28 h1:r+XwaCLpIvCKjBIYy/HVZujQS9tsz5ohHG3ZIe0wKoE= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.28/go.mod h1:3lwChorpIM/BhImY/hy+Z6jekmN92cXGPI1QJasVPYY= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22 h1:7AwGYXDdqRQYsluvKFmWoqpcOQJ4bH634SkYf3FNj/A= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.22/go.mod h1:EqK7gVrIGAHyZItrD1D8B0ilgwMD1GiWAmbU4u/JHNk= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29 h1:J4xhFd6zHhdF9jPP0FQJ6WknzBboGMBNjKOv4iTuw4A= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.29/go.mod h1:TwuqRBGzxjQJIwH16/fOZodwXt2Zxa9/cwJC5ke4j7s= 15 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22 h1:LjFQf8hFuMO22HkV5VWGLBvmCLBCLPivUAmpdpnp4Vs= 16 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.22/go.mod h1:xt0Au8yPIwYXf/GYPy/vl4K3CgwhfQMYbrH7DlUUIws= 17 | github.com/aws/aws-sdk-go-v2/service/ses v1.15.1 h1:k7emv7hC/n/QfqOf8O6CBdAOmP3bVaL9LJcYairC91s= 18 | github.com/aws/aws-sdk-go-v2/service/ses v1.15.1/go.mod h1:fI2JtBv9WeGYXg+rKcd5B9td3Qw0kghB6EX00mAvSlI= 19 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.1 h1:lQKN/LNa3qqu2cDOQZybP7oL4nMGGiFqob0jZJaR8/4= 20 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.1/go.mod h1:IgV8l3sj22nQDd5qcAGY0WenwCzCphqdbFOpfktZPrI= 21 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1 h1:0bLhH6DRAqox+g0LatcjGKjjhU6Eudyys6HB6DJVPj8= 22 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.1/go.mod h1:O1YSOg3aekZibh2SngvCRRG+cRHKKlYgxf/JBF/Kr/k= 23 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 h1:s49mSnsBZEXjfGBkRfmK+nPqzT7Lt3+t2SmAKNyHblw= 24 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.3/go.mod h1:b+psTJn33Q4qGoDaM7ZiOVVG8uVjGI6HaZ8WBHdgDgU= 25 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= 26 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= 27 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 29 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= 30 | github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 31 | github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8= 32 | github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= 33 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 35 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 36 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | golang.org/x/exp v0.0.0-20230206171751-46f607a40771 h1:xP7rWLUr1e1n2xkK5YB4LI0hPEy3LJC6Wk+D4pGlOJg= 40 | golang.org/x/exp v0.0.0-20230206171751-46f607a40771/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 43 | --------------------------------------------------------------------------------