aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRunxi Yu <me@runxiyu.org>2025-09-14 22:28:12 +0800
committerRunxi Yu <me@runxiyu.org>2025-09-14 22:28:12 +0800
commit01e4fd482ebcc827d3c76c00910529abbf666454 (patch)
tree300156b866864a07a0ed7680e6572ae97c20e325
parentUpdate dependencies (diff)
downloadforge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.gz
forge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.zst
forge-01e4fd482ebcc827d3c76c00910529abbf666454.zip
Add basic mailing listspre-refactor
-rw-r--r--forge.scfg22
-rw-r--r--forged/internal/bare/unions.go6
-rw-r--r--forged/internal/unsorted/config.go9
-rw-r--r--forged/internal/unsorted/http_handle_group_index.go129
-rw-r--r--forged/internal/unsorted/http_handle_mailing_lists.go386
-rw-r--r--forged/internal/unsorted/http_handle_repo_upload_pack.go8
-rw-r--r--forged/internal/unsorted/http_server.go49
-rw-r--r--forged/internal/unsorted/lmtp_handle_mlist.go84
-rw-r--r--forged/internal/unsorted/lmtp_server.go9
-rw-r--r--forged/internal/unsorted/smtp_relay.go185
-rw-r--r--forged/static/style.css10
-rw-r--r--forged/templates/_group_view.tmpl25
-rw-r--r--forged/templates/group.tmpl42
-rw-r--r--forged/templates/mailing_list.tmpl56
-rw-r--r--forged/templates/mailing_list_message.tmpl42
-rw-r--r--forged/templates/mailing_list_subscribers.tmpl82
-rw-r--r--sql/schema.sql7
17 files changed, 1098 insertions, 53 deletions
diff --git a/forge.scfg b/forge.scfg
index 1c8eeb9..0e55f87 100644
--- a/forge.scfg
+++ b/forge.scfg
@@ -99,6 +99,28 @@ lmtp {
write_timeout 300
}
+smtp {
+ # Outbound SMTP relay configuration for mailing list delivery
+
+ # What network transport to use (e.g. tcp, tcp4, tcp6)?
+ net tcp
+
+ # Relay address
+ addr 127.0.0.1:25
+
+ hello_name forge.example.org
+
+ # One of "plain", "tls", "starttls".
+ transport plain
+
+ # Allow invalid certs
+ tls_insecure false
+
+ # SMTP auth credentials
+ username ""
+ password ""
+}
+
pprof {
# What network to listen on for pprof?
net tcp
diff --git a/forged/internal/bare/unions.go b/forged/internal/bare/unions.go
index 0270a5f..1020fa0 100644
--- a/forged/internal/bare/unions.go
+++ b/forged/internal/bare/unions.go
@@ -21,8 +21,10 @@ type UnionTags struct {
types map[uint64]reflect.Type
}
-var unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
-var unionRegistry map[reflect.Type]*UnionTags
+var (
+ unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
+ unionRegistry map[reflect.Type]*UnionTags
+)
func init() {
unionRegistry = make(map[reflect.Type]*UnionTags)
diff --git a/forged/internal/unsorted/config.go b/forged/internal/unsorted/config.go
index 9f07480..a8ab718 100644
--- a/forged/internal/unsorted/config.go
+++ b/forged/internal/unsorted/config.go
@@ -36,6 +36,15 @@ type Config struct {
WriteTimeout uint32 `scfg:"write_timeout"`
ReadTimeout uint32 `scfg:"read_timeout"`
} `scfg:"lmtp"`
+ SMTP struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ HelloName string `scfg:"hello_name"`
+ Transport string `scfg:"transport"` // plain, tls, starttls
+ TLSInsecure bool `scfg:"tls_insecure"`
+ Username string `scfg:"username"`
+ Password string `scfg:"password"`
+ } `scfg:"smtp"`
Git struct {
RepoDir string `scfg:"repo_dir"`
Socket string `scfg:"socket"`
diff --git a/forged/internal/unsorted/http_handle_group_index.go b/forged/internal/unsorted/http_handle_group_index.go
index ce28a1c..6ce4840 100644
--- a/forged/internal/unsorted/http_handle_group_index.go
+++ b/forged/internal/unsorted/http_handle_group_index.go
@@ -88,52 +88,72 @@ func (s *Server) httpHandleGroupIndex(writer http.ResponseWriter, request *http.
return
}
- repoName := request.FormValue("repo_name")
- repoDesc := request.FormValue("repo_desc")
- contribReq := request.FormValue("repo_contrib")
- if repoName == "" {
- web.ErrorPage400(s.templates, writer, params, "Repo name is required")
+ switch request.FormValue("op") {
+ case "create_repo":
+ repoName := request.FormValue("repo_name")
+ repoDesc := request.FormValue("repo_desc")
+ contribReq := request.FormValue("repo_contrib")
+ if repoName == "" {
+ web.ErrorPage400(s.templates, writer, params, "Repo name is required")
+ return
+ }
+
+ var newRepoID int
+ err := s.database.QueryRow(
+ request.Context(),
+ `INSERT INTO repos (name, description, group_id, contrib_requirements) VALUES ($1, $2, $3, $4) RETURNING id`,
+ repoName,
+ repoDesc,
+ groupID,
+ contribReq,
+ ).Scan(&newRepoID)
+ if err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error creating repo: "+err.Error())
+ return
+ }
+
+ filePath := filepath.Join(s.config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")
+
+ _, err = s.database.Exec(
+ request.Context(),
+ `UPDATE repos SET filesystem_path = $1 WHERE id = $2`,
+ filePath,
+ newRepoID,
+ )
+ if err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error updating repo path: "+err.Error())
+ return
+ }
+
+ if err = s.gitInit(filePath); err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error initializing repo: "+err.Error())
+ return
+ }
+
+ misc.RedirectUnconditionally(writer, request)
return
- }
-
- var newRepoID int
- err := s.database.QueryRow(
- request.Context(),
- `INSERT INTO repos (name, description, group_id, contrib_requirements)
- VALUES ($1, $2, $3, $4)
- RETURNING id`,
- repoName,
- repoDesc,
- groupID,
- contribReq,
- ).Scan(&newRepoID)
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error creating repo: "+err.Error())
+ case "create_list":
+ listName := request.FormValue("list_name")
+ listDesc := request.FormValue("list_desc")
+ if listName == "" {
+ web.ErrorPage400(s.templates, writer, params, "List name is required")
+ return
+ }
+
+ if _, err := s.database.Exec(
+ request.Context(),
+ `INSERT INTO mailing_lists (name, description, group_id) VALUES ($1, $2, $3)`,
+ listName, listDesc, groupID,
+ ); err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error creating mailing list: "+err.Error())
+ return
+ }
+ misc.RedirectUnconditionally(writer, request)
return
- }
-
- filePath := filepath.Join(s.config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")
-
- _, err = s.database.Exec(
- request.Context(),
- `UPDATE repos
- SET filesystem_path = $1
- WHERE id = $2`,
- filePath,
- newRepoID,
- )
- if err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error updating repo path: "+err.Error())
- return
- }
-
- if err = s.gitInit(filePath); err != nil {
- web.ErrorPage500(s.templates, writer, params, "Error initializing repo: "+err.Error())
+ default:
+ web.ErrorPage400(s.templates, writer, params, "Unknown operation")
return
}
-
- misc.RedirectUnconditionally(writer, request)
- return
}
// Repos
@@ -187,7 +207,32 @@ func (s *Server) httpHandleGroupIndex(writer http.ResponseWriter, request *http.
return
}
+ // Mailing lists
+ var lists []nameDesc
+ {
+ var rows2 pgx.Rows
+ rows2, err = s.database.Query(request.Context(), `SELECT name, COALESCE(description, '') FROM mailing_lists WHERE group_id = $1`, groupID)
+ if err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
+ return
+ }
+ defer rows2.Close()
+ for rows2.Next() {
+ var name, description string
+ if err = rows2.Scan(&name, &description); err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
+ return
+ }
+ lists = append(lists, nameDesc{name, description})
+ }
+ if err = rows2.Err(); err != nil {
+ web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
+ return
+ }
+ }
+
params["repos"] = repos
+ params["mailing_lists"] = lists
params["subgroups"] = subgroups
params["description"] = groupDesc
params["direct_access"] = directAccess
diff --git a/forged/internal/unsorted/http_handle_mailing_lists.go b/forged/internal/unsorted/http_handle_mailing_lists.go
new file mode 100644
index 0000000..9739078
--- /dev/null
+++ b/forged/internal/unsorted/http_handle_mailing_lists.go
@@ -0,0 +1,386 @@
+// 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
+
+ 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)
+ }
+}
diff --git a/forged/internal/unsorted/http_handle_repo_upload_pack.go b/forged/internal/unsorted/http_handle_repo_upload_pack.go
index 914c9cc..4aebae3 100644
--- a/forged/internal/unsorted/http_handle_repo_upload_pack.go
+++ b/forged/internal/unsorted/http_handle_repo_upload_pack.go
@@ -108,11 +108,15 @@ func decodeBody(r *http.Request) (io.ReadCloser, error) {
return r.Body, nil
case "gzip":
zr, err := gzip.NewReader(r.Body)
- if err != nil { return nil, err }
+ if err != nil {
+ return nil, err
+ }
return zr, nil
case "deflate":
zr, err := zlib.NewReader(r.Body)
- if err != nil { return nil, err }
+ if err != nil {
+ return nil, err
+ }
return zr, nil
default:
return nil, fmt.Errorf("unsupported Content-Encoding: %q", ce)
diff --git a/forged/internal/unsorted/http_server.go b/forged/internal/unsorted/http_server.go
index f6a1794..aa9c90c 100644
--- a/forged/internal/unsorted/http_server.go
+++ b/forged/internal/unsorted/http_server.go
@@ -268,6 +268,55 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
web.ErrorPage404(s.templates, writer, params)
return
}
+ case "lists":
+ params["list_name"] = moduleName
+
+ if len(segments) == sepIndex+3 {
+ if misc.RedirectDir(writer, request) {
+ return
+ }
+ s.httpHandleMailingListIndex(writer, request, params)
+ return
+ }
+
+ feature := segments[sepIndex+3]
+ switch feature {
+ case "raw":
+ if len(segments) != sepIndex+5 {
+ web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
+ return
+ }
+ if misc.RedirectNoDir(writer, request) {
+ return
+ }
+ params["email_id"] = segments[sepIndex+4]
+ s.httpHandleMailingListRaw(writer, request, params)
+ return
+ case "message":
+ if len(segments) != sepIndex+5 {
+ web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
+ return
+ }
+ if misc.RedirectNoDir(writer, request) {
+ return
+ }
+ params["email_id"] = segments[sepIndex+4]
+ s.httpHandleMailingListMessage(writer, request, params)
+ return
+ case "subscribers":
+ if len(segments) != sepIndex+4 {
+ web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
+ return
+ }
+ if misc.RedirectDir(writer, request) {
+ return
+ }
+ s.httpHandleMailingListSubscribers(writer, request, params)
+ return
+ default:
+ web.ErrorPage404(s.templates, writer, params)
+ return
+ }
default:
web.ErrorPage404(s.templates, writer, params)
return
diff --git a/forged/internal/unsorted/lmtp_handle_mlist.go b/forged/internal/unsorted/lmtp_handle_mlist.go
new file mode 100644
index 0000000..321d65d
--- /dev/null
+++ b/forged/internal/unsorted/lmtp_handle_mlist.go
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+package unsorted
+
+import (
+ "context"
+ "errors"
+ "net/mail"
+ "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)
+ 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
+}
+
+// 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
+}
diff --git a/forged/internal/unsorted/lmtp_server.go b/forged/internal/unsorted/lmtp_server.go
index a006679..e1f3cab 100644
--- a/forged/internal/unsorted/lmtp_server.go
+++ b/forged/internal/unsorted/lmtp_server.go
@@ -20,7 +20,7 @@ import (
"go.lindenii.runxiyu.org/forge/forged/internal/misc"
)
-type lmtpHandler struct{s *Server}
+type lmtpHandler struct{ s *Server }
type lmtpSession struct {
from string
@@ -59,7 +59,7 @@ func (h *lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
session := &lmtpSession{
ctx: ctx,
cancel: cancel,
- s: h.s,
+ s: h.s,
}
return session, nil
}
@@ -184,6 +184,11 @@ func (session *lmtpSession) Data(r io.Reader) error {
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)
+ goto end
+ }
default:
err = errors.New("Emailing any endpoint other than repositories, is not supported yet.") // TODO
goto end
diff --git a/forged/internal/unsorted/smtp_relay.go b/forged/internal/unsorted/smtp_relay.go
new file mode 100644
index 0000000..b90c8bd
--- /dev/null
+++ b/forged/internal/unsorted/smtp_relay.go
@@ -0,0 +1,185 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+package unsorted
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ stdsmtp "net/smtp"
+ "time"
+
+ "github.com/emersion/go-sasl"
+ "github.com/emersion/go-smtp"
+)
+
+// relayMailingListMessage connects to the configured SMTP relay and sends the
+// raw message to all subscribers of the given list. The message is written verbatim
+// from this point on and there is no modification of any headers or whatever,
+func (s *Server) relayMailingListMessage(ctx context.Context, listID int, envelopeFrom string, raw []byte) error {
+ rows, err := s.database.Query(ctx, `SELECT email FROM mailing_list_subscribers WHERE list_id = $1`, listID)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+ var recipients []string
+ for rows.Next() {
+ var email string
+ if err = rows.Scan(&email); err != nil {
+ return err
+ }
+ recipients = append(recipients, email)
+ }
+ if err = rows.Err(); err != nil {
+ return err
+ }
+ if len(recipients) == 0 {
+ slog.Info("mailing list has no subscribers", "list_id", listID)
+ return nil
+ }
+
+ netw := s.config.SMTP.Net
+ if netw == "" {
+ netw = "tcp"
+ }
+ if s.config.SMTP.Addr == "" {
+ return errors.New("smtp relay addr not configured")
+ }
+ helloName := s.config.SMTP.HelloName
+ if helloName == "" {
+ helloName = s.config.LMTP.Domain
+ }
+ transport := s.config.SMTP.Transport
+ if transport == "" {
+ transport = "plain"
+ }
+
+ switch transport {
+ case "plain", "tls":
+ d := net.Dialer{Timeout: 30 * time.Second}
+ var conn net.Conn
+ var err error
+ if transport == "tls" {
+ tlsCfg := &tls.Config{ServerName: hostFromAddr(s.config.SMTP.Addr), InsecureSkipVerify: s.config.SMTP.TLSInsecure}
+ conn, err = tls.DialWithDialer(&d, netw, s.config.SMTP.Addr, tlsCfg)
+ } else {
+ conn, err = d.DialContext(ctx, netw, s.config.SMTP.Addr)
+ }
+ if err != nil {
+ return fmt.Errorf("dial smtp: %w", err)
+ }
+ defer conn.Close()
+
+ c := smtp.NewClient(conn)
+ defer c.Close()
+
+ if err := c.Hello(helloName); err != nil {
+ return fmt.Errorf("smtp hello: %w", err)
+ }
+
+ if s.config.SMTP.Username != "" {
+ mech := sasl.NewPlainClient("", s.config.SMTP.Username, s.config.SMTP.Password)
+ if err := c.Auth(mech); err != nil {
+ return fmt.Errorf("smtp auth: %w", err)
+ }
+ }
+
+ if err := c.Mail(envelopeFrom, &smtp.MailOptions{}); err != nil {
+ return fmt.Errorf("smtp mail from: %w", err)
+ }
+ for _, rcpt := range recipients {
+ if err := c.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil {
+ return fmt.Errorf("smtp rcpt %s: %w", rcpt, err)
+ }
+ }
+ wc, err := c.Data()
+ if err != nil {
+ return fmt.Errorf("smtp data: %w", err)
+ }
+ if _, err := wc.Write(raw); err != nil {
+ _ = wc.Close()
+ return fmt.Errorf("smtp write: %w", err)
+ }
+ if err := wc.Close(); err != nil {
+ return fmt.Errorf("smtp data close: %w", err)
+ }
+ if err := c.Quit(); err != nil {
+ return fmt.Errorf("smtp quit: %w", err)
+ }
+ return nil
+ case "starttls":
+ d := net.Dialer{Timeout: 30 * time.Second}
+ conn, err := d.DialContext(ctx, netw, s.config.SMTP.Addr)
+ if err != nil {
+ return fmt.Errorf("dial smtp: %w", err)
+ }
+ defer conn.Close()
+
+ host := hostFromAddr(s.config.SMTP.Addr)
+ c, err := stdsmtp.NewClient(conn, host)
+ if err != nil {
+ return fmt.Errorf("smtp new client: %w", err)
+ }
+ defer c.Close()
+
+ if err := c.Hello(helloName); err != nil {
+ return fmt.Errorf("smtp hello: %w", err)
+ }
+ if ok, _ := c.Extension("STARTTLS"); !ok {
+ return errors.New("smtp server does not support STARTTLS")
+ }
+ tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: s.config.SMTP.TLSInsecure} // #nosec G402
+ if err := c.StartTLS(tlsCfg); err != nil {
+ return fmt.Errorf("starttls: %w", err)
+ }
+
+ // seems like ehlo is required after starttls
+ if err := c.Hello(helloName); err != nil {
+ return fmt.Errorf("smtp hello (post-starttls): %w", err)
+ }
+
+ if s.config.SMTP.Username != "" {
+ auth := stdsmtp.PlainAuth("", s.config.SMTP.Username, s.config.SMTP.Password, host)
+ if err := c.Auth(auth); err != nil {
+ return fmt.Errorf("smtp auth: %w", err)
+ }
+ }
+ if err := c.Mail(envelopeFrom); err != nil {
+ return fmt.Errorf("smtp mail from: %w", err)
+ }
+ for _, rcpt := range recipients {
+ if err := c.Rcpt(rcpt); err != nil {
+ return fmt.Errorf("smtp rcpt %s: %w", rcpt, err)
+ }
+ }
+ wc, err := c.Data()
+ if err != nil {
+ return fmt.Errorf("smtp data: %w", err)
+ }
+ if _, err := wc.Write(raw); err != nil {
+ _ = wc.Close()
+ return fmt.Errorf("smtp write: %w", err)
+ }
+ if err := wc.Close(); err != nil {
+ return fmt.Errorf("smtp data close: %w", err)
+ }
+ if err := c.Quit(); err != nil {
+ return fmt.Errorf("smtp quit: %w", err)
+ }
+ return nil
+ default:
+ return fmt.Errorf("unknown smtp transport: %q", transport)
+ }
+}
+
+func hostFromAddr(addr string) string {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil || host == "" {
+ return addr
+ }
+ return host
+}
diff --git a/forged/static/style.css b/forged/static/style.css
index 4923771..8c6c6d5 100644
--- a/forged/static/style.css
+++ b/forged/static/style.css
@@ -544,11 +544,11 @@ td > ul {
.repo-header > .nav-tabs-standalone {
border: none;
- margin: 0;
- flex-grow: 1;
- display: inline-flex;
- flex-wrap: nowrap;
- padding: 0;
+ margin: 0;
+ flex-grow: 1;
+ display: inline-flex;
+ flex-wrap: nowrap;
+ padding: 0;
}
.repo-header {
diff --git a/forged/templates/_group_view.tmpl b/forged/templates/_group_view.tmpl
index 92b6639..ca8062d 100644
--- a/forged/templates/_group_view.tmpl
+++ b/forged/templates/_group_view.tmpl
@@ -53,4 +53,29 @@
</tbody>
</table>
{{- end -}}
+{{- if .mailing_lists -}}
+ <table class="wide">
+ <thead>
+ <tr>
+ <th colspan="2" class="title-row">Mailing lists</th>
+ </tr>
+ <tr>
+ <th scope="col">Name</th>
+ <th scope="col">Description</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{- range .mailing_lists -}}
+ <tr>
+ <td>
+ <a href="-/lists/{{- .Name | path_escape -}}/">{{- .Name -}}</a>
+ </td>
+ <td>
+ {{- .Description -}}
+ </td>
+ </tr>
+ {{- end -}}
+ </tbody>
+ </table>
+{{- end -}}
{{- end -}}
diff --git a/forged/templates/group.tmpl b/forged/templates/group.tmpl
index 3338f9b..531559a 100644
--- a/forged/templates/group.tmpl
+++ b/forged/templates/group.tmpl
@@ -31,6 +31,7 @@
</tr>
</thead>
<tbody>
+ <input type="hidden" name="op" value="create_repo" />
<tr>
<th scope="row">Name</th>
<td class="tdinput">
@@ -72,6 +73,47 @@
</table>
</form>
</div>
+ <div class="padding-wrapper">
+ <form method="POST" enctype="application/x-www-form-urlencoded">
+ <table>
+ <thead>
+ <tr>
+ <th class="title-row" colspan="2">
+ Create mailing list
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <input type="hidden" name="op" value="create_list" />
+ <tr>
+ <th scope="row">Name</th>
+ <td class="tdinput">
+ <input id="list-name-input" name="list_name" type="text" />
+ </td>
+ </tr>
+ <tr>
+ <th scope="row">Description</th>
+ <td class="tdinput">
+ <input id="list-desc-input" name="list_desc" type="text" />
+ </td>
+ </tr>
+ </tbody>
+ <tfoot>
+ <tr>
+ <td class="th-like" colspan="2">
+ <div class="flex-justify">
+ <div class="left">
+ </div>
+ <div class="right">
+ <input class="btn-primary" type="submit" value="Create" />
+ </div>
+ </div>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ </form>
+ </div>
{{- end -}}
</main>
<footer>
diff --git a/forged/templates/mailing_list.tmpl b/forged/templates/mailing_list.tmpl
new file mode 100644
index 0000000..9144253
--- /dev/null
+++ b/forged/templates/mailing_list.tmpl
@@ -0,0 +1,56 @@
+{{/*
+ 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>
+ {{- 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 -}}
diff --git a/forged/templates/mailing_list_message.tmpl b/forged/templates/mailing_list_message.tmpl
new file mode 100644
index 0000000..7bd24d6
--- /dev/null
+++ b/forged/templates/mailing_list_message.tmpl
@@ -0,0 +1,42 @@
+{{/*
+ SPDX-License-Identifier: AGPL-3.0-only
+*/}}
+{{- define "mailing_list_message" -}}
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ {{- template "head_common" . -}}
+ <title>{{ .email_subject }} – {{ .global.forge_title -}}</title>
+ </head>
+ <body class="mailing-list-message">
+ {{- template "header" . -}}
+ <main>
+ <div class="padding-wrapper">
+ <table class="wide">
+ <thead>
+ <tr>
+ <th colspan="2" class="title-row">{{ .email_subject }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th scope="row">From</th>
+ <td>{{ .email_from }}</td>
+ </tr>
+ <tr>
+ <th scope="row">Date</th>
+ <td>{{ if .email_date.IsZero }}{{ .email_date_raw }}{{ else }}{{ .email_date }}{{ end }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div class="padding-wrapper">
+ <div class="readme">{{ .email_body_html }}</div>
+ </div>
+ </main>
+ <footer>
+ {{- template "footer" . -}}
+ </footer>
+ </body>
+</html>
+{{- end -}}
diff --git a/forged/templates/mailing_list_subscribers.tmpl b/forged/templates/mailing_list_subscribers.tmpl
new file mode 100644
index 0000000..f0e88f5
--- /dev/null
+++ b/forged/templates/mailing_list_subscribers.tmpl
@@ -0,0 +1,82 @@
+{{/*
+ SPDX-License-Identifier: AGPL-3.0-only
+ SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+*/}}
+{{- define "mailing_list_subscribers" -}}
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ {{- template "head_common" . -}}
+ <title>{{ .list_name }} subscribers – {{ .global.forge_title -}}</title>
+ </head>
+ <body class="mailing-list-subscribers">
+ {{- template "header" . -}}
+ <main>
+ <div class="padding-wrapper">
+ <table class="wide">
+ <thead>
+ <tr>
+ <th colspan="2" class="title-row">Subscribers for {{ .list_name }}</th>
+ </tr>
+ <tr>
+ <th scope="col">Email</th>
+ <th scope="col">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{- range .subscribers -}}
+ <tr>
+ <td>{{ .email }}</td>
+ <td>
+ {{- if $.direct_access -}}
+ <form method="POST" enctype="application/x-www-form-urlencoded">
+ <input type="hidden" name="op" value="remove" />
+ <input type="hidden" name="id" value="{{ .id }}" />
+ <input class="btn-danger" type="submit" value="Remove" />
+ </form>
+ {{- end -}}
+ </td>
+ </tr>
+ {{- end -}}
+ </tbody>
+ </table>
+ </div>
+ {{- if .direct_access -}}
+ <div class="padding-wrapper">
+ <form method="POST" enctype="application/x-www-form-urlencoded">
+ <table>
+ <thead>
+ <tr>
+ <th class="title-row" colspan="2">Add subscriber</th>
+ </tr>
+ </thead>
+ <tbody>
+ <input type="hidden" name="op" value="add" />
+ <tr>
+ <th scope="row">Email</th>
+ <td class="tdinput"><input id="subscriber-email" name="email" type="email" /></td>
+ </tr>
+ </tbody>
+ <tfoot>
+ <tr>
+ <td class="th-like" colspan="2">
+ <div class="flex-justify">
+ <div class="left"></div>
+ <div class="right">
+ <input class="btn-primary" type="submit" value="Add" />
+ </div>
+ </div>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ </form>
+ </div>
+ {{- end -}}
+ </main>
+ <footer>
+ {{- template "footer" . -}}
+ </footer>
+ </body>
+</html>
+{{- end -}}
diff --git a/sql/schema.sql b/sql/schema.sql
index 92ae605..7805377 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -36,6 +36,13 @@ CREATE TABLE mailing_list_emails (
content BYTEA NOT NULL
);
+CREATE TABLE mailing_list_subscribers (
+ id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ list_id INTEGER NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ UNIQUE (list_id, email)
+);
+
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT UNIQUE,