Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
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 -}}