aboutsummaryrefslogtreecommitdiff
path: root/forged/internal/unsorted
diff options
context:
space:
mode:
Diffstat (limited to 'forged/internal/unsorted')
-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
8 files changed, 813 insertions, 46 deletions
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
+}