aboutsummaryrefslogtreecommitdiff
path: root/forged/internal/unsorted/lmtp_server.go
diff options
context:
space:
mode:
Diffstat (limited to 'forged/internal/unsorted/lmtp_server.go')
-rw-r--r--forged/internal/unsorted/lmtp_server.go204
1 files changed, 204 insertions, 0 deletions
diff --git a/forged/internal/unsorted/lmtp_server.go b/forged/internal/unsorted/lmtp_server.go
new file mode 100644
index 0000000..1e94894
--- /dev/null
+++ b/forged/internal/unsorted/lmtp_server.go
@@ -0,0 +1,204 @@
+// 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 unsorted
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/emersion/go-message"
+ "github.com/emersion/go-smtp"
+ "go.lindenii.runxiyu.org/forge/forged/internal/misc"
+)
+
+type lmtpHandler struct{}
+
+type lmtpSession struct {
+ from string
+ to []string
+ ctx context.Context
+ cancel context.CancelFunc
+ s Server
+}
+
+func (session *lmtpSession) Reset() {
+ session.from = ""
+ session.to = nil
+}
+
+func (session *lmtpSession) Logout() error {
+ session.cancel()
+ 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 (session *lmtpSession) Rcpt(to string, _ *smtp.RcptOptions) error {
+ session.to = append(session.to, to)
+ return nil
+}
+
+func (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
+ ctx, cancel := context.WithCancel(context.Background())
+ session := &lmtpSession{
+ ctx: ctx,
+ cancel: cancel,
+ }
+ return session, nil
+}
+
+func (s *Server) serveLMTP(listener net.Listener) error {
+ smtpServer := smtp.NewServer(&lmtpHandler{})
+ smtpServer.LMTP = true
+ smtpServer.Domain = s.config.LMTP.Domain
+ smtpServer.Addr = s.config.LMTP.Socket
+ smtpServer.WriteTimeout = time.Duration(s.config.LMTP.WriteTimeout) * time.Second
+ smtpServer.ReadTimeout = time.Duration(s.config.LMTP.ReadTimeout) * time.Second
+ smtpServer.EnableSMTPUTF8 = true
+ 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, session.s.config.LMTP.MaxSize)
+ switch {
+ case n == session.s.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 task is run.
+ from = session.from
+ to = session.to
+
+ _ = from
+
+ for _, to := range to {
+ if !strings.HasSuffix(to, "@"+session.s.config.LMTP.Domain) {
+ continue
+ }
+ localPart := to[:len(to)-len("@"+session.s.config.LMTP.Domain)]
+ var segments []string
+ segments, err = misc.PathToSegments(localPart)
+ if err != nil {
+ // TODO: Should the entire email fail or should we just
+ // notify them out of band?
+ err = fmt.Errorf("cannot parse path: %w", err)
+ goto end
+ }
+ sepIndex := -1
+ for i, part := range segments {
+ if part == "-" {
+ sepIndex = i
+ break
+ }
+ }
+ if segments[len(segments)-1] == "" {
+ segments = segments[:len(segments)-1] // We don't care about dir or not.
+ }
+ if sepIndex == -1 || len(segments) <= sepIndex+2 {
+ err = errors.New("illegal path")
+ goto end
+ }
+
+ mbox := bytes.Buffer{}
+ if _, err = fmt.Fprint(&mbox, "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"); err != nil {
+ slog.Error("error handling patch... malloc???", "error", err)
+ goto end
+ }
+ data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
+ if _, err = mbox.Write(data); err != nil {
+ slog.Error("error handling patch... malloc???", "error", err)
+ goto end
+ }
+ // TODO: Is mbox's From escaping necessary here?
+
+ groupPath := segments[:sepIndex]
+ moduleType := segments[sepIndex+1]
+ moduleName := segments[sepIndex+2]
+ switch moduleType {
+ case "repos":
+ err = session.s.lmtpHandlePatch(session, groupPath, moduleName, &mbox)
+ if err != nil {
+ slog.Error("error handling patch", "error", err)
+ goto end
+ }
+ default:
+ err = errors.New("Emailing any endpoint other than repositories, is not supported yet.") // TODO
+ goto end
+ }
+ }
+
+end:
+ session.to = nil
+ session.from = ""
+ switch err {
+ case nil:
+ return nil
+ default:
+ return &smtp.SMTPError{
+ Code: 550,
+ Message: "Permanent failure: " + err.Error(),
+ EnhancedCode: [3]int{5, 7, 1},
+ }
+ }
+}