Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon

Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!

Commit info
ID
98b250df38ca82f08ad0b6ee19eace3dfb182ede
Author
Runxi Yu <me@runxiyu.org>
Author date
Thu, 25 Sep 2025 08:23:20 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Thu, 25 Sep 2025 08:23:20 +0800
Actions
Mailing lists: implement sub/unsub
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"bytes"
	"errors"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/mail"
	"strconv"
	"strings"
	"time"

	"github.com/emersion/go-message"
	"github.com/jackc/pgx/v5"
	"github.com/microcosm-cc/bluemonday"
	"go.lindenii.runxiyu.org/forge/forged/internal/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/render"
	"go.lindenii.runxiyu.org/forge/forged/internal/web"
)

// httpHandleMailingListIndex renders the page for a single mailing list.
func (s *Server) httpHandleMailingListIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var (
		listID    int
		listDesc  string
		emailRows pgx.Rows
		emails    []map[string]any
	)

	if err := s.database.QueryRow(request.Context(),
		`SELECT id, COALESCE(description, '') FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID, &listDesc); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	emailRows, err = s.database.Query(request.Context(), `SELECT id, title, sender, date FROM mailing_list_emails WHERE list_id = $1 ORDER BY date DESC, id DESC LIMIT 200`, listID)
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading list emails: "+err.Error())
		return
	}
	defer emailRows.Close()

	for emailRows.Next() {
		var (
			id            int
			title, sender string
			dateVal       time.Time
		)
		if err := emailRows.Scan(&id, &title, &sender, &dateVal); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error scanning list emails: "+err.Error())
			return
		}
		emails = append(emails, map[string]any{
			"id":     id,
			"title":  title,
			"sender": sender,
			"date":   dateVal,
		})
	}
	if err := emailRows.Err(); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error iterating list emails: "+err.Error())
		return
	}

	params["list_name"] = listName
	params["list_description"] = listDesc
	params["list_emails"] = emails

	listURLRoot := "/"
	segments := params["url_segments"].([]string)
	for _, part := range segments[:params["separator_index"].(int)+3] {
		listURLRoot += part + "/"
	}
	params["list_email_address"] = listURLRoot[1:len(listURLRoot)-1] + "@" + s.config.LMTP.Domain
	localPart := listURLRoot[1 : len(listURLRoot)-1]
	params["list_email_address"] = localPart + "@" + s.config.LMTP.Domain
	params["list_subscribe_address"] = listURLRoot[1:] + "subscribe@" + s.config.LMTP.Domain
	params["list_unsubscribe_address"] = listURLRoot[1:] + "unsubscribe@" + s.config.LMTP.Domain

	var count int
	if err := s.database.QueryRow(request.Context(), `
	SELECT COUNT(*) FROM user_group_roles WHERE user_id = $1 AND group_id = $2
	`, params["user_id"].(int), groupID).Scan(&count); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
		return
	}
	params["direct_access"] = (count > 0)

	s.renderTemplate(writer, "mailing_list", params)
}

// httpHandleMailingListRaw serves a raw email by ID from a list.
func (s *Server) httpHandleMailingListRaw(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)
	idStr := params["email_id"].(string)
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		web.ErrorPage400(s.templates, writer, params, "Invalid email id")
		return
	}

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var content []byte
	if err := s.database.QueryRow(request.Context(),
		`SELECT content FROM mailing_list_emails WHERE id = $1 AND list_id = $2`, id, listID,
	).Scan(&content); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading email content: "+err.Error())
		return
	}

	writer.Header().Set("Content-Type", "message/rfc822")
	writer.WriteHeader(http.StatusOK)
	_, _ = writer.Write(content)
}

// httpHandleMailingListSubscribers lists and manages the subscribers for a mailing list.
func (s *Server) httpHandleMailingListSubscribers(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var count int
	if err := s.database.QueryRow(request.Context(), `SELECT COUNT(*) FROM user_group_roles WHERE user_id = $1 AND group_id = $2`, params["user_id"].(int), groupID).Scan(&count); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
		return
	}
	directAccess := (count > 0)
	if request.Method == http.MethodPost {
		if !directAccess {
			web.ErrorPage403(s.templates, writer, params, "You do not have direct access to this list")
			return
		}
		switch request.FormValue("op") {
		case "add":
			email := strings.TrimSpace(request.FormValue("email"))
			if email == "" || !strings.Contains(email, "@") {
				web.ErrorPage400(s.templates, writer, params, "Valid email is required")
				return
			}
			if _, err := s.database.Exec(request.Context(),
				`INSERT INTO mailing_list_subscribers (list_id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
				listID, email,
			); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error adding subscriber: "+err.Error())
				return
			}
			misc.RedirectUnconditionally(writer, request)
			return
		case "remove":
			idStr := request.FormValue("id")
			id, err := strconv.Atoi(idStr)
			if err != nil || id <= 0 {
				web.ErrorPage400(s.templates, writer, params, "Invalid id")
				return
			}
			if _, err := s.database.Exec(request.Context(),
				`DELETE FROM mailing_list_subscribers WHERE id = $1 AND list_id = $2`, id, listID,
			); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error removing subscriber: "+err.Error())
				return
			}
			misc.RedirectUnconditionally(writer, request)
			return
		default:
			web.ErrorPage400(s.templates, writer, params, "Unknown operation")
			return
		}
	}

	rows, err := s.database.Query(request.Context(), `SELECT id, email FROM mailing_list_subscribers WHERE list_id = $1 ORDER BY email ASC`, listID)
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading subscribers: "+err.Error())
		return
	}
	defer rows.Close()
	var subs []map[string]any
	for rows.Next() {
		var id int
		var email string
		if err := rows.Scan(&id, &email); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error scanning subscribers: "+err.Error())
			return
		}
		subs = append(subs, map[string]any{"id": id, "email": email})
	}
	if err := rows.Err(); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error iterating subscribers: "+err.Error())
		return
	}

	params["list_name"] = listName
	params["subscribers"] = subs
	params["direct_access"] = directAccess

	s.renderTemplate(writer, "mailing_list_subscribers", params)
}

// httpHandleMailingListMessage renders a single archived message.
func (s *Server) httpHandleMailingListMessage(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)
	idStr := params["email_id"].(string)
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		web.ErrorPage400(s.templates, writer, params, "Invalid email id")
		return
	}

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var raw []byte
	if err := s.database.QueryRow(request.Context(),
		`SELECT content FROM mailing_list_emails WHERE id = $1 AND list_id = $2`, id, listID,
	).Scan(&raw); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading email content: "+err.Error())
		return
	}

	entity, err := message.Read(bytes.NewReader(raw))
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error parsing email content: "+err.Error())
		return
	}

	subj := entity.Header.Get("Subject")
	from := entity.Header.Get("From")
	dateStr := entity.Header.Get("Date")
	var dateVal time.Time
	if t, err := mail.ParseDate(dateStr); err == nil {
		dateVal = t
	}

	isHTML, body := extractBody(entity)
	var bodyHTML any
	if isHTML {
		bodyHTML = bluemonday.UGCPolicy().SanitizeBytes([]byte(body))
	} else {
		bodyHTML = render.EscapeHTML(body)
	}

	params["email_subject"] = subj
	params["email_from"] = from
	params["email_date_raw"] = dateStr
	params["email_date"] = dateVal
	params["email_body_html"] = bodyHTML

	s.renderTemplate(writer, "mailing_list_message", params)
}

func extractBody(e *message.Entity) (bool, string) {
	ctype := e.Header.Get("Content-Type")
	mtype, params, _ := mime.ParseMediaType(ctype)
	var plain string
	var htmlBody string

	if strings.HasPrefix(mtype, "multipart/") {
		b := params["boundary"]
		if b == "" {
			data, _ := io.ReadAll(e.Body)
			return false, string(data)
		}
		mr := multipart.NewReader(e.Body, b)
		for {
			part, err := mr.NextPart()
			if errors.Is(err, io.EOF) {
				break
			}
			if err != nil {
				break
			}
			ptype, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
			pdata, _ := io.ReadAll(part)
			switch strings.ToLower(ptype) {
			case "text/plain":
				if plain == "" {
					plain = string(pdata)
				}
			case "text/html":
				if htmlBody == "" {
					htmlBody = string(pdata)
				}
			}
		}
		if plain != "" {
			return false, plain
		}
		if htmlBody != "" {
			return true, htmlBody
		}
		return false, ""
	}

	data, _ := io.ReadAll(e.Body)
	switch strings.ToLower(mtype) {
	case "", "text/plain":
		return false, string(data)
	case "text/html":
		return true, string(data)
	default:
		return false, string(data)
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"context"
	"errors"
	"log/slog"
	"net/mail"
	"strings"
	"time"

	"github.com/emersion/go-message"
	"github.com/jackc/pgx/v5"
)

// lmtpHandleMailingList stores an incoming email into the mailing list archive
// for the specified group/list. It expects the list to be already existing.
func (s *Server) lmtpHandleMailingList(session *lmtpSession, groupPath []string, listName string, email *message.Entity, raw []byte, envelopeFrom string) error {
	ctx := session.ctx

	groupID, err := s.resolveGroupPath(ctx, groupPath)
	_, listID, err := s.resolveMailingList(ctx, groupPath, listName)
	if err != nil {
		return err
	}

	var listID int
	if err := s.database.QueryRow(ctx,
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return errors.New("mailing list not found")
		}
		return err
	}

	title := email.Header.Get("Subject")
	sender := email.Header.Get("From")

	date := time.Now()
	if dh := email.Header.Get("Date"); dh != "" {
		if t, err := mail.ParseDate(dh); err == nil {
			date = t
		}
	}

	_, err = s.database.Exec(ctx, `INSERT INTO mailing_list_emails (list_id, title, sender, date, content) VALUES ($1, $2, $3, $4, $5)`, listID, title, sender, date, raw)
	if err != nil {
		return err
	}

	if derr := s.relayMailingListMessage(ctx, listID, envelopeFrom, raw); derr != nil {
		// for now, return the error to LMTP so the sender learns delivery failed...
		// should replace this with queueing or something nice
		return derr
	}

	return nil
}

// lmtpHandleMailingListSubscribe subscribes the envelope sender to the mailing list without
// any additional confirmation.
func (s *Server) lmtpHandleMailingListSubscribe(session *lmtpSession, groupPath []string, listName string, envelopeFrom string) error {
	ctx := session.ctx
	_, listID, err := s.resolveMailingList(ctx, groupPath, listName)
	if err != nil {
		return err
	}

	address, err := normalizeEnvelopeAddress(envelopeFrom)
	if err != nil {
		return err
	}

	if _, err := s.database.Exec(ctx,
		`INSERT INTO mailing_list_subscribers (list_id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
		listID, address,
	); err != nil {
		return err
	}

	slog.Info("mailing list subscription",
		"group", strings.Join(groupPath, "/"),
		"list", listName,
		"email", address,
	)

	return nil
}

// lmtpHandleMailingListUnsubscribe removes the envelope sender from the mailing list.
func (s *Server) lmtpHandleMailingListUnsubscribe(session *lmtpSession, groupPath []string, listName string, envelopeFrom string) error {
	ctx := session.ctx
	_, listID, err := s.resolveMailingList(ctx, groupPath, listName)
	if err != nil {
		return err
	}

	address, err := normalizeEnvelopeAddress(envelopeFrom)
	if err != nil {
		return err
	}

	if _, err := s.database.Exec(ctx,
		`DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND email = $2`,
		listID, address,
	); err != nil {
		return err
	}

	slog.Info("mailing list unsubscription",
		"group", strings.Join(groupPath, "/"),
		"list", listName,
		"email", address,
	)

	return nil
}

func (s *Server) resolveMailingList(ctx context.Context, groupPath []string, listName string) (int, int, error) {
	groupID, err := s.resolveGroupPath(ctx, groupPath)
	if err != nil {
		return 0, 0, err
	}

	var listID int
	if err := s.database.QueryRow(ctx,
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, 0, errors.New("mailing list not found")
		}
		return 0, 0, err
	}

	return groupID, listID, nil
}

func normalizeEnvelopeAddress(envelopeFrom string) (string, error) {
	envelopeFrom = strings.TrimSpace(envelopeFrom)
	if envelopeFrom == "" || envelopeFrom == "<>" {
		return "", errors.New("envelope sender required")
	}
	addr, err := mail.ParseAddress(envelopeFrom)
	if err != nil {
		trimmed := strings.Trim(envelopeFrom, "<>")
		if trimmed == "" {
			return "", errors.New("envelope sender required")
		}
		if !strings.Contains(trimmed, "@") {
			return "", err
		}
		return strings.ToLower(trimmed), nil
	}
	return strings.ToLower(addr.Address), nil
}

// resolveGroupPath resolves a group path (segments) to a group ID.
func (s *Server) resolveGroupPath(ctx context.Context, groupPath []string) (int, error) {
	var groupID int
	err := s.database.QueryRow(ctx, `
	WITH RECURSIVE group_path_cte AS (
		SELECT id, parent_group, name, 1 AS depth
		FROM groups
		WHERE name = ($1::text[])[1]
			AND parent_group IS NULL

		UNION ALL

		SELECT g.id, g.parent_group, g.name, group_path_cte.depth + 1
		FROM groups g
		JOIN group_path_cte ON g.parent_group = group_path_cte.id
		WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
			AND group_path_cte.depth + 1 <= cardinality($1::text[])
	)
	SELECT c.id
	FROM group_path_cte c
	WHERE c.depth = cardinality($1::text[])
	`, groupPath).Scan(&groupID)
	return groupID, err
}
// 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{ s *Server }

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 (h *lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
	ctx, cancel := context.WithCancel(context.Background())
	session := &lmtpSession{
		ctx:    ctx,
		cancel: cancel,
		s:      h.s,
	}
	return session, nil
}

func (s *Server) serveLMTP(listener net.Listener) error {
	smtpServer := smtp.NewServer(&lmtpHandler{s: s})
	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
	)

	fmt.Printf("%#v\n", session.s)
	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
			}
		case "lists":
			if err = session.s.lmtpHandleMailingList(session, groupPath, moduleName, email, data, from); err != nil {
				slog.Error("error handling mailing list message", "error", err)
			var moduleAction string
			if len(segments) > sepIndex+3 {
				moduleAction = segments[sepIndex+3]
				if len(segments) > sepIndex+4 {
					err = errors.New("too many path segments for mailing list command")
					goto end
				}
			}

			switch moduleAction {
			case "":
				if err = session.s.lmtpHandleMailingList(session, groupPath, moduleName, email, data, from); err != nil {
					slog.Error("error handling mailing list message", "error", err)
					goto end
				}
			case "subscribe":
				if err = session.s.lmtpHandleMailingListSubscribe(session, groupPath, moduleName, from); err != nil {
					slog.Error("error handling mailing list subscribe", "error", err)
					goto end
				}
			case "unsubscribe":
				if err = session.s.lmtpHandleMailingListUnsubscribe(session, groupPath, moduleName, from); err != nil {
					slog.Error("error handling mailing list unsubscribe", "error", err)
					goto end
				}
			default:
				err = fmt.Errorf("unsupported mailing list command: %q", moduleAction)
				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},
		}
	}
}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "mailing_list" -}}
<!DOCTYPE html>
<html lang="en">
 	<head>
		{{- template "head_common" . -}}
		<title>{{- index .group_path 0 -}}{{- range $i, $s := .group_path -}}{{- if gt $i 0 -}}/{{- $s -}}{{- end -}}{{- end }}/-/lists/{{ .list_name }} – {{ .global.forge_title -}}</title>
	</head>
	<body class="mailing-list">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				<h2>{{ .list_name }}</h2>
				{{- if .list_description -}}
				<p>{{ .list_description }}</p>
				{{- end -}}
				<p><strong>Address:</strong> <code>{{ .list_email_address }}</code></p>
				<p><strong>Subscribe:</strong> <code>{{ .list_subscribe_address }}</code></p>
				<p><strong>Unsubscribe:</strong> <code>{{ .list_unsubscribe_address }}</code></p>
				{{- if .direct_access -}}
				<p><a href="subscribers/">Manage subscribers</a></p>
				{{- end -}}
			</div>
			<div class="padding-wrapper">
				<table class="wide">
					<thead>
						<tr>
							<th colspan="4" class="title-row">Archive</th>
						</tr>
						<tr>
							<th scope="col">Title</th>
							<th scope="col">Sender</th>
							<th scope="col">Date</th>
							<th scope="col">Raw</th>
						</tr>
					</thead>
					<tbody>
						{{- range .list_emails -}}
						<tr>
							<td><a href="message/{{ .id }}">{{ .title }}</a></td>
							<td>{{ .sender }}</td>
							<td>{{ .date }}</td>
							<td><a href="raw/{{ .id }}">download</a></td>
						</tr>
						{{- end -}}
					</tbody>
				</table>
			</div>
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}