From a8eee4110fe52e132411e4d171e3e08d22fb0079 Mon Sep 17 00:00:00 2001
From: Runxi Yu <me@runxiyu.org>
Date: Tue, 01 Apr 2025 13:27:26 +0800
Subject: [PATCH] Basic debugging LMTP handler

---
 .golangci.yaml |   8 ++++++++
 config.go      |   3 ++-
 forge.scfg     |   3 +++
 go.mod         |   3 +++
 go.sum         |   7 +++++++
 lmtp_server.go | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++-

diff --git a/.golangci.yaml b/.golangci.yaml
index 760bb070945de8aeaff92162d965e6ca45ca981b..00ba1ea7cafe82c9c91607f24365b136daf1c5b7 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -14,6 +14,8 @@     - mnd              # it's a bit ridiculous to replace all of them
     - nakedret         # patterns should be consistent
     - nonamedreturns   # i like named returns
     - wrapcheck        # wrapping all errors is just not necessary
+    - varnamelen       # "from" and "to" are very valid
+    - stylecheck
     - maintidx    # e
     - nestif      # e
     - gocognit    # e
@@ -26,6 +28,12 @@     - wsl         # e
     - nlreturn    # e
     - unused      # e
     - exhaustruct # e
+
+linters-settings:
+  revive:
+    rules:
+      - name: error-strings
+        disabled: true
 
 issues:
   max-issues-per-linter: 0
diff --git a/config.go b/config.go
index 97e25881463d73898b93b9f7ab658638a446023b..7870a63e26d8990cf3abc5aba15bb8a2afff9603 100644
--- a/config.go
+++ b/config.go
@@ -32,7 +32,8 @@ 		Socket string `scfg:"socket"`
 		Execs  string `scfg:"execs"`
 	} `scfg:"hooks"`
 	LMTP struct {
-		Socket string `scfg:"socket"`
+		Socket  string `scfg:"socket"`
+		MaxSize int64  `scfg:"max_size"`
 	} `scfg:"lmtp"`
 	Git struct {
 		RepoDir string `scfg:"repo_dir"`
diff --git a/forge.scfg b/forge.scfg
index abf4f80a6bd9882019a2396291e76e1f40be4f38..b2a34cd80076f68f31ffcd7586f955cfdf67b11b 100644
--- a/forge.scfg
+++ b/forge.scfg
@@ -80,4 +80,7 @@
 lmtp {
 	# On which UNIX domain socket should we listen for LMTP on?
 	socket /var/run/lindenii/forge/lmtp.sock
+
+	# What's the maximum acceptable message size?
+	max_size 1000000
 }
diff --git a/go.mod b/go.mod
index 9e20462a65bbe37a9e1b7faf78ad31d85e142508..6555e3b99749e3abd1cecea00c3a6275b59d4f3a 100644
--- a/go.mod
+++ b/go.mod
@@ -29,6 +29,9 @@ 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudflare/circl v1.6.0 // indirect
 	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
+	github.com/emersion/go-message v0.18.2 // indirect
+	github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
+	github.com/emersion/go-smtp v0.21.3 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-git/go-billy/v5 v5.6.2 // indirect
diff --git a/go.sum b/go.sum
index c6c498d551e5c845e323ea2367ebb3546e583ba4..70aa113c54c0f6a6727f003345ea7e5f1240359c 100644
--- a/go.sum
+++ b/go.sum
@@ -38,6 +38,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
+github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
+github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
+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.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
+github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -169,6 +175,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
 golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/lmtp_server.go b/lmtp_server.go
index 57f319a680acda4a440259bc5353177de2c81a73..8e574e47d1feb555a7fb0543b70a9b8230d64e64 100644
--- a/lmtp_server.go
+++ b/lmtp_server.go
@@ -1,10 +1,123 @@
 // SPDX-License-Identifier: AGPL-3.0-only
 // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+// SPDX-FileCopyrightText: Copyright (c) 2024 Robin Jarry <robin@jarry.cc>
 
 package main
 
-import "net"
+import (
+	"bytes"
+	"errors"
+	"io"
+	"log/slog"
+	"net"
+	"strings"
+
+	"github.com/emersion/go-message"
+	"github.com/emersion/go-smtp"
+)
+
+type lmtpHandler struct{}
+
+type lmtpSession struct {
+	from string
+	to   []string
+}
+
+func (session *lmtpSession) Reset() {
+	session.from = ""
+	session.to = nil
+}
+
+func (session *lmtpSession) Logout() error {
+	return nil
+}
+
+func (session *lmtpSession) AuthPlain(_, _ string) error {
+	return nil
+}
+
+func (session *lmtpSession) Mail(from string, _ *smtp.MailOptions) error {
+	session.from = from
+	return nil
+}
 
-func serveLMTP(_ net.Listener) error {
+func (session *lmtpSession) Rcpt(to string, _ *smtp.RcptOptions) error {
+	session.to = append(session.to, to)
 	return nil
 }
+
+func (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
+	// TODO
+	session := &lmtpSession{}
+	return session, nil
+}
+
+func serveLMTP(listener net.Listener) error {
+	smtpServer := smtp.NewServer(&lmtpHandler{})
+	return smtpServer.Serve(listener)
+}
+
+func (session *lmtpSession) Data(r io.Reader) error {
+	var (
+		email *message.Entity
+		from  string
+		to    []string
+		err   error
+		buf   bytes.Buffer
+		data  []byte
+		n     int64
+	)
+
+	n, err = io.CopyN(&buf, r, config.LMTP.MaxSize)
+	switch {
+	case n == config.LMTP.MaxSize:
+		err = errors.New("Message too big.")
+		// drain whatever is left in the pipe
+		_, _ = io.Copy(io.Discard, r)
+		goto end
+	case errors.Is(err, io.EOF):
+		// message was smaller than max size
+		break
+	case err != nil:
+		goto end
+	}
+
+	data = buf.Bytes()
+
+	email, err = message.Read(bytes.NewReader(data))
+	if err != nil && message.IsUnknownCharset(err) {
+		goto end
+	}
+
+	switch strings.ToLower(email.Header.Get("Auto-Submitted")) {
+	case "auto-generated", "auto-replied":
+		// disregard automatic emails like OOO replies
+		slog.Info("ignoring automatic message",
+			"from", session.from,
+			"to", strings.Join(session.to, ","),
+			"message-id", email.Header.Get("Message-Id"),
+			"subject", email.Header.Get("Subject"),
+		)
+		goto end
+	}
+
+	slog.Info("message received",
+		"from", session.from,
+		"to", strings.Join(session.to, ","),
+		"message-id", email.Header.Get("Message-Id"),
+		"subject", email.Header.Get("Subject"),
+	)
+
+	// Make local copies of the values before to ensure the references will
+	// still be valid when the queued task function is evaluated.
+	from = session.from
+	to = session.to
+
+	// TODO: Process the actual message contents
+	_, _ = from, to
+
+end:
+	session.to = nil
+	session.from = ""
+	return err
+}

-- 
2.48.1