Created
December 15, 2020 13:52
-
-
Save prologic/a6c9a9c5ae27736fd75f1abc2213f63c to your computer and use it in GitHub Desktop.
Twtxt SMTP Server for Private Messaging delivery
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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