Skip to content

Instantly share code, notes, and snippets.

@prologic
Created December 15, 2020 13:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save prologic/a6c9a9c5ae27736fd75f1abc2213f63c to your computer and use it in GitHub Desktop.
Save prologic/a6c9a9c5ae27736fd75f1abc2213f63c to your computer and use it in GitHub Desktop.
Twtxt SMTP Server for Private Messaging delivery
package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"encoding/hex"
"fmt"
"hash"
"net"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/emersion/go-mbox"
"github.com/emersion/go-message"
"github.com/prologic/smtpd"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
const (
msgsDir = "msgs"
headerKeyTo = "To"
headerKeyFrom = "From"
)
type Config struct {
Data string
}
func writeMessage(conf *Config, msg *message.Entity, username string) error {
p := filepath.Join(conf.Data, msgsDir)
if err := os.MkdirAll(p, 0755); err != nil {
log.WithError(err).Error("error creating msgs directory")
return err
}
fn := filepath.Join(p, username)
f, err := os.OpenFile(fn, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return err
}
defer f.Close()
from := msg.Header.Get(headerKeyFrom)
if from == "" {
return fmt.Errorf("error no `From` header found in message")
}
w := mbox.NewWriter(f)
defer w.Close()
mw, err := w.CreateMessage(from, time.Now())
if err != nil {
log.WithError(err).Error("error creating message writer")
return fmt.Errorf("error creating message writer: %w", err)
}
if err := msg.WriteTo(mw); err != nil {
log.Fatal(err)
}
return nil
}
func parseAddresses(addrs []string) ([]*mail.Address, error) {
var addresses []*mail.Address
for _, addr := range addrs {
address, err := mail.ParseAddress(addr)
if err != nil {
log.WithError(err).Error("error parsing address")
return nil, fmt.Errorf("error parsing address %s: %w", addr, err)
}
addresses = append(addresses, address)
}
return addresses, nil
}
func storeMessage(conf *Config, msg *message.Entity, to []string) error {
addresses, err := parseAddresses(to)
if err != nil {
log.WithError(err).Error("error parsing `To` address list")
return fmt.Errorf("error parsing `To` address list: %w", err)
}
for _, address := range addresses {
username, _ := splitEmailAddress(address.Address)
if err := writeMessage(conf, msg, username); err != nil {
log.WithError(err).Error("error writing message for %s", username)
return fmt.Errorf("error writing message for %s: %w", username, err)
}
}
return nil
}
func splitEmailAddress(email string) (string, string) {
components := strings.Split(email, "@")
username, domain := components[0], components[1]
return username, domain
}
func validMAC(fn func() hash.Hash, message, messageMAC, key []byte) bool {
mac := hmac.New(fn, key)
mac.Write(message)
expectedMAC := mac.Sum(nil)
return hmac.Equal(messageMAC, expectedMAC)
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
if string(username) != "admin" {
return false, fmt.Errorf("error invalid credentials")
}
if mechanism == "CRAM-MD5" {
messageMac := make([]byte, hex.DecodedLen(len(password)))
n, err := hex.Decode(messageMac, password)
if err != nil {
return false, err
}
return validMAC(md5.New, shared, messageMac[:n], []byte("admin")), nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), 10)
if err != nil {
return false, err
}
err = bcrypt.CompareHashAndPassword(hash, password)
return err == nil, err
}
func rcptHandler(remoteAddr net.Addr, from string, to string) bool {
_, domain := splitEmailAddress(to)
return domain == "twtxt.net"
}
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
msg, err := message.Read(bytes.NewReader(data))
if message.IsUnknownCharset(err) {
log.WithError(err).Warn("unknown encoding")
} else if err != nil {
log.WithError(err).Error("error parsing message")
return fmt.Errorf("error parsing message: %w", err)
}
conf := &Config{Data: "./"}
if err := storeMessage(conf, msg, to); err != nil {
log.WithError(err).Error("error storing message")
return fmt.Errorf("error storing message: %w", err)
}
return nil
}
func listenAndServe(addr string, handler smtpd.Handler, rcpt smtpd.HandlerRcpt) error {
authMechs := map[string]bool{"PLAIN": true, "LOGIN": true}
srv := &smtpd.Server{
Addr: addr,
Handler: mailHandler,
HandlerRcpt: rcptHandler,
Appname: "Twtxt SMTP v0.1.0",
Hostname: "twtxt.net",
AuthMechs: authMechs,
AuthHandler: authHandler,
AuthRequired: true,
}
return srv.ListenAndServe()
}
func main() {
listenAndServe("0.0.0.0:25", mailHandler, rcptHandler)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment