Simple SMTP server to forward your emails

If you have many ideas and for each idea you have a domain, like I do, and for each of this domains you want to receive emails, just in case someone wants to rich you, soon you will be in the situation to pay for each of this domains a monthly fee and probably for nothing. As I have all my websites hosted on GCP in containers I thought that it would be cheaper, free actually, to host my own SMTP server to receive all the emails for all the domains, forward them to my gmail account and also to save a copy to Cloud Storage, just to have them there. In this post I will show how this can be done.

TL;DR

Check out this github.com/gabihodoroaga/smtpd-email-forward repository if you want to get started to create your own smtp server.

Prerequisites

The goals

Let’s start by setting up our goals:

  • a smtp server to receive the email
  • a GCP bucket to store the emails
  • forward the email to another mailbox using Mailgun

The project setup

Create the folder and initialize the golang modules

mkdir smtp-email-forward
cd smtp-email-forward
go mod init github.com/gabihodoroaga/smtpd-email-forward

The SMTP server

For this I used this package github.com/mhale/smtpd. Add smtpd/server.go file with the following content

package smtpd

import (
	"context"
	"net"
	"strings"

	"github.com/gabihodoroaga/smtpd-email-forward/config"
	"github.com/gabihodoroaga/smtpd-email-forward/logger"
	"github.com/gabihodoroaga/smtpd-email-forward/storage"
	"github.com/gabihodoroaga/smtpd-email-forward/forwarder"
	"github.com/mhale/smtpd"
)

// StartServer initialize and start the smtp server on
func StartServer() {
	addr := "0.0.0.0:2525"
	logger.Log.Infof("Start listening on: %s", addr)
	err := listenAndServeTLS(
		addr,
		config.Config.StartTLSCert,
		config.Config.StartTLSKey,
		mailHandler,
		rcptHandler,
		config.Config.AppName,
		config.Config.Hostname)
	if err != nil {
		panic(err)
	}
}

func mailHandler(origin net.Addr, from string, to []string, data []byte) {
	logger.Log.Infof("Received mail from %s for %s", from, strings.Join(to, ";"))
	// save the email to GCP bucket
	if config.Config.GCPBucket != "" {
		ctx := context.Background()
		for _, m := range to {
			filename, err := storage.UploadFileToBucket(ctx, m, data)
			if err != nil {
				logger.Log.Errorf("Error saving file %s to bucket: %s", filename, err)
			} else {
				logger.Log.Infof("Save mail from %s for %s to file %s", from, to, filename)
			}
		}
	}
	// forward the emails
	err := forwarder.ForwardEmail(data)

	if err != nil {
		logger.Log.Errorf("Errors forward email:%s", err)
	}
}

func rcptHandler(remoteAddr net.Addr, from string, to string) bool {
	domain := getDomain(to)
	if domain == "" {
		return false
	}
	for _, b := range config.Config.Domains {
		if b == domain {
			return true
		}
	}
	return false
}

func getDomain(email string) string {
	at := strings.LastIndex(email, "@")
	if at >= 0 {
		return email[at+1:]
	}
	return ""
}

func listenAndServeTLS(addr string, certFile string, keyFile string, handler smtpd.Handler, rcpt smtpd.HandlerRcpt, appname string, hostname string) error {
	srv := &smtpd.Server{
		Addr:        addr,
		Handler:     handler,
		HandlerRcpt: rcpt,
		Appname:     appname,
		Hostname:    hostname,
		TLSRequired: false}
	err := srv.ConfigureTLS(certFile, keyFile)
	if err != nil {
		return err
	}
	return srv.ListenAndServe()
}

What this code does

  • setup the smtp server and start listening on port 2525: StartServer()
  • setup our recipient handler to make sure we accept email for the configured domains: rcptHandler()
  • setup the email handler, the function where we save the emails to a GCP Bucket and we forward de emails: mailHandler()

For logging we will use Uber’s “blazing fast, structured, leveled logging”. Add a new file to the project logger/logger.go

package logger

import (
		"go.uber.org/zap"
)

var Log *zap.SugaredLogger

func InitLogger() {
	logger, err := zap.NewProduction() // or NewProduction, or NewDevelopment
	if err != nil {
		panic("Cannot create the logger")
	}
	defer logger.Sync()
	Log = logger.Sugar()
}

Next let’s add the implementation of the storage.UploadFileToBucket() function, add the file storage/storage.go with the following content

package storage

import (
	"context"
	"fmt"
	"strconv"
	"sync"
	"time"

	"cloud.google.com/go/storage"
	"github.com/gabihodoroaga/smtpd-email-forward/config"
	"google.golang.org/api/option"
)

type storageConnection struct {
	Client *storage.Client
}

var (
	client *storageConnection
	once   sync.Once
)

// UploadFileToBucket uploads the email message to GCP bucket, one folder for each mailbox
func UploadFileToBucket(ctx context.Context, mailbox string, data []byte) (string, error) {
	filename := generateFileNameForGCS(ctx, "email")
	filepath := fmt.Sprintf("%s/%s.eml", mailbox, filename)
	client, err := getGCSClient(ctx)
	if err != nil {
		return "", err
	}
	wc := client.Bucket(config.Config.GCPBucket).Object(filepath).NewWriter(ctx)
	if _, err = wc.Write(data); err != nil {
		return "", err
	}
	if err := wc.Close(); err != nil {
		return "", err
	}
	return filepath, nil
}

func generateFileNameForGCS(ctx context.Context, name string) string {
	time := time.Now().UnixNano()
	var strArr []string
	strArr = append(strArr, name)
	strArr = append(strArr, strconv.Itoa(int(time)))
	var filename string
	for _, str := range strArr {
		filename = filename + str
	}
	return filename
}

func getGCSClient(ctx context.Context) (*storage.Client, error) {
	var clientErr error
	once.Do(func() {
		storageClient, err := storage.NewClient(ctx, option.WithCredentialsFile(config.Config.GCPCredentials))
		if err != nil {
			clientErr = fmt.Errorf("Failed to create GCS client ERROR:%s", err.Error())
		} else {
			client = &storageConnection{
				Client: storageClient,
			}
		}
	})
	return client.Client, clientErr
}

, and the implementation of forwarder.ForwardEmail() function, add the file forwarder/service.go

package forwarder

import (
	"errors"
	"strings"

	"github.com/gabihodoroaga/smtpd-email-forward/config"
)

var forwarders []mailForwarder

type mailForwarder interface {
	ForwardEmail(data []byte, recipient string) error
}

// InitForwarders initialize all the email forwarders
func InitForwarders() {
	initMailGun()
}

// ForwardEmail forwards the email to all the configured forwarders
func ForwardEmail(data []byte) error {
	var errstrings []string
	for _, f := range forwarders {
		if err := f.ForwardEmail(data, config.Config.ForwardTo); err != nil {
			errstrings = append(errstrings, err.Error())
		}
	}
	if len(errstrings) > 0 {
		return errors.New(strings.Join(errstrings, "\n"))
	}
	return nil
}

This is the abstract implementation of the forwarder, for the concrete implementation we will use mailgun. Add this file forwarder/mailgun.go

package forwarder

import (
	"bytes"
	"context"
	"io/ioutil"
	"time"

	"github.com/gabihodoroaga/smtpd-email-forward/config"
	"github.com/gabihodoroaga/smtpd-email-forward/logger"
	"github.com/mailgun/mailgun-go/v4"
)

type mailgunForwarder struct {
	Domain string
	APIKey string
	URL string
}

func initMailGun() {
	if config.Config.MailgunDomain != "" && config.Config.MailgunAPIKey != "" {
		logger.Log.Info("Init Mailgun forwarder")
		forwarders = append(forwarders, &mailgunForwarder{
			Domain: config.Config.MailgunDomain,
			APIKey: config.Config.MailgunAPIKey,
			URL: config.Config.MailgunURL,
		})
	}
}

func (f mailgunForwarder) ForwardEmail(data []byte, recipient string) error {
	// create the client
	mg := mailgun.NewMailgun(f.Domain, f.APIKey)
	mg.SetAPIBase(f.URL)

	// create the message
	message := mg.NewMIMEMessage(ioutil.NopCloser(bytes.NewReader(data)), recipient)
	// create a context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
	defer cancel()
	// Send the message with a 10 second timeout
	resp, id, err := mg.Send(ctx, message)

	if err != nil {
		return err
	}

	logger.Log.Infof("Email sent successfully to mailgun: ID: %s Resp: %s", id, resp)

	return nil
}

Now, we need to add the configuration package in order to dynamically configure our service. Add the file config/config.go

package config

import (
	"flag"
	"fmt"
	"os"

	"github.com/gabihodoroaga/smtpd-email-forward/logger"
	"github.com/ilyakaznacheev/cleanenv"
)

type args struct {
	ConfigPath string
}

// Configuration struct ...
type Configuration struct {
	AppName        string   `yaml:"appname" env:"APP_NAME"`
	Hostname       string   `yaml:"hostname" env:"HOST_NAME"`
	Domains        []string `yaml:"domains" env:"DOMAINS" env-separator:","`
	StartTLSCert   string   `yaml:"starttlscert" env:"START_TLS_CERT" env-default:"certs/server.crt"`
	StartTLSKey    string   `yaml:"starttlskey" env:"START_TLS_KEY" env-default:"certs/server.key"`
	GCPBucket      string   `yaml:"gcpbucket" env:"GCP_BUCKET"`
	GCPCredentials string   `yaml:"gcpcredentials" env:"GCP_CREDENTIALS" env-default:"certs/gcpServiceAccount.json"`
	ForwardTo      string   `yaml:"forwardto" env:"FORWARD_TO"`
	MailgunDomain  string   `yaml:"mailgundomain" env:"MAILGUN_DOMAIN"`
	MailgunAPIKey  string   `yaml:"mailgunapikey" env:"MAILGUN_API_KEY"`
	MailgunURL     string   `yaml:"mailgunurl" env:"MAILGUN_URL" env-default:"https://api.mailgun.net/v3"`
}

// Config is the application configuration
var Config Configuration

// InitConfig - Initialize the the application configuration
func InitConfig() {
	args := processArgs(&Config)
	// read configuration from the file and environment variables
	if err := cleanenv.ReadConfig(args.ConfigPath, &Config); err != nil {
		fmt.Println(err)
		os.Exit(2)
	}

	// TODO: print configuration values
	logger.Log.Info("Configuration loaded successfully")
}

// ProcessArgs processes and handles CLI arguments
func processArgs(cfg interface{}) args {
	var a args

	f := flag.NewFlagSet("Example server", 1)
	f.StringVar(&a.ConfigPath, "c", "config.yml", "Path to configuration file")

	fu := f.Usage
	f.Usage = func() {
		fu()
		envHelp, _ := cleanenv.GetDescription(cfg, nil)
		fmt.Fprintln(f.Output())
		fmt.Fprintln(f.Output(), envHelp)
	}

	f.Parse(os.Args[1:])
	return a
}

To read the configuration I used an excellent package from Ilya Kaznacheev, because it allows you to read the configuration form the file and from the environment variables combined.

To wrapped up we need to add also the cmd package. Add the file cmd/server.go

package main

import (
	"github.com/gabihodoroaga/smtpd-email-forward/config"
	"github.com/gabihodoroaga/smtpd-email-forward/logger"
	"github.com/gabihodoroaga/smtpd-email-forward/smtpd"
	"github.com/gabihodoroaga/smtpd-email-forward/forwarder"
)

func main() {
	logger.InitLogger()
	logger.Log.Info("smtpd server is starting...")
	config.InitConfig()
	forwarder.InitForwarders()
	smtpd.StartServer()
}

We are almost done, let’s see if we can build

cd cmd
go build

To actually run the server we need create all the external resources

The Mailgun account

  • navigate to https://signup.mailgun.com/new/signup and create a new account
  • and then go to Sending/Overview select API and write down the sandbox domain and the API Key
  • it might be that you must configure an authorized recipient if you used the free account
MAILGUN_DOMAIN=[your_mailgun_sandbox_domain]
MAILGUN_API_KEY=[your_mailgun_api_key]

The GCP bucket

GCP_BUCKET=smtpd-email-forward-123
gsutil mb -c nearline gs://$GCP_BUCKET

if you get the error “ServiceException: 409 Bucket smtpd-email-forward-123 already exists.” just change the name of the bucket, as I already use it when I tested the setup.

The GCP service account

# create the service account
gcloud iam service-accounts create smtpd-email-forward-123 \
    --description="A service account to write to the bucker" \
    --display-name="smtpd-email-forward-123"

# grab the email
GCP_SERVICE_ACCOUNT=$(gcloud iam service-accounts list --format="value(email)" --filter="displayName=smtpd-email-forward-123")

# give the service account the permission to create objects
gsutil iam ch serviceAccount:$GCP_SERVICE_ACCOUNT:objectCreator gs://$GCP_BUCKET

# create and save the service account key
mkdir certs
gcloud iam service-accounts keys create certs/gcpServiceAccount.json \
  --iam-account $GCP_SERVICE_ACCOUNT

The server certificates

openssl req -x509 -newkey rsa:4096 -keyout ./certs/server.key -out ./certs/server.crt -days 365 -nodes

The default configuration file cmd/config.yml

appname: gcp-smtpd
starttlscert: ../certs/server.crt
starttlskey: ../certs/server.key
gcpcredentials: ../certs/gcpServiceAccount.json
forwardto: claire@example.com
domains:
  - example.com

and start up the server

cd cmd
./cmd

to test your server run the following command on a separate terminal

telnet localhost 2525
HELO test.example.com
MAIL FROM:<bob@example.com>
RCPT TO:<alice@example.com>
DATA
From: "Bob Example" <bob@example.com>
To: Alice Example <alice@example.com>
Cc: theboss@example.com
Date: Tue, 15 Jan 2008 16:02:43 -0500
Subject: Test message
 
Hello Alice.
This is a test message with 5 header fields and 4 lines in the message body.
Your friend,
Bob
.
QUIT

Done. You have you own smtp server.

Deploy to GCP

You should be able to run this server locally without any issue, but in order to deploy to GCP there are a few more actions to do

Create the Dockerfile

FROM alpine:3.7

WORKDIR /app

ADD ./dist/smtpd /app/smtpd
ADD ./cmd/config.yml /app/config.yml

EXPOSE 2525/tcp

CMD ["/app/smtpd"]

Create the cloudbuild.yaml file

steps:
- name: golang:1.13
  entrypoint: /bin/sh
  args:
  - '-c'
  - |
    # build server
    env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s" -o dist/smtpd ./cmd    
- name: 'gcr.io/cloud-builders/docker'
  args: [ 'build', '-t', 'gcr.io/$PROJECT_ID/smtp-email-forward:${_VERSION_}', '.' ]
images:
- 'gcr.io/$PROJECT_ID/smtp-email-forward:${_VERSION_}'
substitutions:
  _VERSION_: 1.0.0

Trigger cloud build to push the image to the registry

gcloud builds submit --config cloudbuild.yaml .

Create the compute instance and set it the smtp server image

PROJECT_ID=$(gcloud config list --format 'value(core.project)')
DOCKER_IMAGE=gcr.io/$PROJECT_ID/smtp-email-forward
VM_NAME=smtp-email-forward-vm
ZONE=us-central1-a

gcloud compute instances create-with-container $VM_NAME \
    --zone $ZONE \
    --machine-type=e2-micro \
    --tags $VM_NAME-smtp \
    --container-image $DOCKER_IMAGE

Configure the firewall rules

gcloud compute firewall-rules create $VM_NAME-allow-smtp \
    --allow tcp:2525 \
    --source-ranges 0.0.0.0/0 \
    --target-tags $VM_NAME-http

Test the server

IP_ADDRESS=$(gcloud compute instances describe $VM_NAME \
    --zone=$ZONE \
    --format='get(networkInterfaces[0].accessConfigs[0].natIP)')

telnet $IP_ADDRESS 2525

If you setup the port 25 and is not working make sure that you ISP is not blocking the port 25, most of the residential ISP will block the outgoing port 25.

And the last step is to configure you MX record for your domain. Check this article on Wikipedia

In order to avoid unnecessary chargers you should delete all resources used in this tutorial

gcloud -q compute firewall-rules delete $VM_NAME-allow-smtp
gcloud -q compute instances delete $VM_NAME --zone $ZONE
gcloud -q container images delete $DOCKER_IMAGE:1.0.0
gcloud -q iam service-accounts delete $SQL_SERVICE_ACCOUNT

Conclusion

If you only want to be able to forward your email to another mailbox, you can use this project and configure it for unlimited number of domains and an unlimited number of email addresses and save some money. It will cost around 5$/month on GCP.

You can find all the full source code here github.com/gabihodoroaga/smtpd-email-forward