From 01e4fd482ebcc827d3c76c00910529abbf666454 Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sun, 14 Sep 2025 22:28:12 +0800 Subject: [PATCH] Add basic mailing lists --- forge.scfg | 22 ++++++++++++++++++++++ forged/internal/bare/unions.go | 6 ++++-- forged/internal/unsorted/config.go | 9 +++++++++ forged/internal/unsorted/http_handle_group_index.go | 123 ++++++++++++++++++++++++++++++++++++----------------- forged/internal/unsorted/http_handle_mailing_lists.go | 386 +++++++++++++++++++++++++++++++++++++++++++++++++++++ forged/internal/unsorted/http_handle_repo_upload_pack.go | 8 ++++++-- forged/internal/unsorted/http_server.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ forged/internal/unsorted/lmtp_handle_mlist.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++ forged/internal/unsorted/lmtp_server.go | 9 +++++++-- forged/internal/unsorted/smtp_relay.go | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++ forged/static/style.css | 10 +++++----- forged/templates/_group_view.tmpl | 25 +++++++++++++++++++++++++ forged/templates/group.tmpl | 42 ++++++++++++++++++++++++++++++++++++++++++ forged/templates/mailing_list.tmpl | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++ forged/templates/mailing_list_message.tmpl | 42 ++++++++++++++++++++++++++++++++++++++++++ forged/templates/mailing_list_subscribers.tmpl | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++ sql/schema.sql | 7 +++++++ diff --git a/forge.scfg b/forge.scfg index 1c8eeb94ba2aa8fbb4d7abf1404a65f759b3ecbd..0e55f87d0d139bb526f1f29b4aabc3d863c446e9 100644 --- a/forge.scfg +++ b/forge.scfg @@ -99,6 +99,28 @@ read_timeout 300 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 0270a5f8f20d8311c27e51bb6f17703be5170158..1020fa0d45ac1c6647f704c25b9769d2dd7a2133 100644 --- a/forged/internal/bare/unions.go +++ b/forged/internal/bare/unions.go @@ -21,8 +21,10 @@ tags map[reflect.Type]uint64 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 9f07480ed574ef76016d5fb9d964b4158a9149b6..a8ab718e81eb3d7b5dfee29843021cfaca408884 100644 --- a/forged/internal/unsorted/config.go +++ b/forged/internal/unsorted/config.go @@ -36,6 +36,15 @@ MaxSize int64 `scfg:"max_size"` 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 ce28a1c3b81ea6cd5cb7735c9e603f9021b1be21..6ce48407c6a50cd670b780a23e54a380ee768e9b 100644 --- a/forged/internal/unsorted/http_handle_group_index.go +++ b/forged/internal/unsorted/http_handle_group_index.go @@ -88,52 +88,72 @@ web.ErrorPage403(s.templates, writer, params, "You do not have direct access to this group") 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") - return - } + 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 - } + 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") + 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 + } - _, 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()) + misc.RedirectUnconditionally(writer, request) return - } + 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.gitInit(filePath); err != nil { - web.ErrorPage500(s.templates, writer, params, "Error initializing repo: "+err.Error()) + 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 + default: + web.ErrorPage400(s.templates, writer, params, "Unknown operation") return } - - misc.RedirectUnconditionally(writer, request) - return } // Repos @@ -187,7 +207,32 @@ web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error()) 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 0000000000000000000000000000000000000000..973907818dfc575d56d422d6b9e31b83bb2d3177 --- /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 + +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 914c9cc9601d563e576507f216a65925251be384..4aebae39d8b45a5eb0f68def745b433f53355422 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 @@ case "", "identity": 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 f6a17941c73055d845715e92768b525339543275..aa9c90cd9b9460eeb3d1c403e19048030d4c3c1f 100644 --- a/forged/internal/unsorted/http_server.go +++ b/forged/internal/unsorted/http_server.go @@ -268,6 +268,55 @@ default: 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 0000000000000000000000000000000000000000..321d65dfc386a2022bac8c64532fb74fb87cb731 --- /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 + +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 a006679a232a80b5953f1f233bc820bb0ade070f..e1f3cab06121f7cbff94ad3ef77bc839c5129e28 100644 --- a/forged/internal/unsorted/lmtp_server.go +++ b/forged/internal/unsorted/lmtp_server.go @@ -20,7 +20,7 @@ "github.com/emersion/go-smtp" "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 @@ ctx, cancel := context.WithCancel(context.Background()) session := &lmtpSession{ ctx: ctx, cancel: cancel, - s: h.s, + s: h.s, } return session, nil } @@ -182,6 +182,11 @@ 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) goto end } default: diff --git a/forged/internal/unsorted/smtp_relay.go b/forged/internal/unsorted/smtp_relay.go new file mode 100644 index 0000000000000000000000000000000000000000..b90c8bd265e652ea5ad13d8a8153c50c3ceac2e0 --- /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 + +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 49237719e011526a9f12eca7b92e910138157436..8c6c6d5ef41a1bc0b93b7683dc870928730ae830 100644 --- a/forged/static/style.css +++ b/forged/static/style.css @@ -544,11 +544,11 @@ } .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 92b66390e0ec1409837a48fcf7694804f26ebabd..ca8062dca686f31a61c6c54b83d72dd1512ef044 100644 --- a/forged/templates/_group_view.tmpl +++ b/forged/templates/_group_view.tmpl @@ -53,4 +53,29 @@ {{- end -}} {{- end -}} +{{- if .mailing_lists -}} + + + + + + + + + + + + {{- range .mailing_lists -}} + + + + + {{- end -}} + +
Mailing lists
NameDescription
+ {{- .Name -}} + + {{- .Description -}} +
+{{- end -}} {{- end -}} diff --git a/forged/templates/group.tmpl b/forged/templates/group.tmpl index 3338f9b0c1b19ee53b60d4e5df86131b5c9a3be2..531559a7622b3eaeb8749560068bed8e995f5bca 100644 --- a/forged/templates/group.tmpl +++ b/forged/templates/group.tmpl @@ -31,6 +31,7 @@ + Name @@ -53,6 +54,47 @@ + + + + + + +
+
+
+
+ +
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + diff --git a/forged/templates/mailing_list.tmpl b/forged/templates/mailing_list.tmpl new file mode 100644 index 0000000000000000000000000000000000000000..9144253e52c30a7cb006dc9c4fbca231c5087d47 --- /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 +*/}} +{{- define "mailing_list" -}} + + + + {{- template "head_common" . -}} + {{- index .group_path 0 -}}{{- range $i, $s := .group_path -}}{{- if gt $i 0 -}}/{{- $s -}}{{- end -}}{{- end }}/-/lists/{{ .list_name }} – {{ .global.forge_title -}} + + + {{- template "header" . -}} +
+
+

{{ .list_name }}

+ {{- if .list_description -}} +

{{ .list_description }}

+ {{- end -}} +

Address: {{ .list_email_address }}

+ {{- if .direct_access -}} +

Manage subscribers

+ {{- end -}} +
+
+
+ Create mailing list +
Name + +
Description +
+ + + + + + + + + + + + + {{- range .list_emails -}} + + + + + + + {{- end -}} + +
Archive
TitleSenderDateRaw
{{ .title }}{{ .sender }}{{ .date }}download
+
+ +
+ {{- template "footer" . -}} +
+ + +{{- end -}} diff --git a/forged/templates/mailing_list_message.tmpl b/forged/templates/mailing_list_message.tmpl new file mode 100644 index 0000000000000000000000000000000000000000..7bd24d6a90df3cf6ed8425a08ff0cac8b792e4cb --- /dev/null +++ b/forged/templates/mailing_list_message.tmpl @@ -0,0 +1,42 @@ +{{/* + SPDX-License-Identifier: AGPL-3.0-only +*/}} +{{- define "mailing_list_message" -}} + + + + {{- template "head_common" . -}} + {{ .email_subject }} – {{ .global.forge_title -}} + + + {{- template "header" . -}} +
+
+ + + + + + + + + + + + + + + + +
{{ .email_subject }}
From{{ .email_from }}
Date{{ if .email_date.IsZero }}{{ .email_date_raw }}{{ else }}{{ .email_date }}{{ end }}
+
+
+
{{ .email_body_html }}
+
+
+
+ {{- template "footer" . -}} +
+ + +{{- end -}} diff --git a/forged/templates/mailing_list_subscribers.tmpl b/forged/templates/mailing_list_subscribers.tmpl new file mode 100644 index 0000000000000000000000000000000000000000..f0e88f5f2ec136b3d2dedd49a4c0feae2b0a3acf --- /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 +*/}} +{{- define "mailing_list_subscribers" -}} + + + + {{- template "head_common" . -}} + {{ .list_name }} subscribers – {{ .global.forge_title -}} + + + {{- template "header" . -}} +
+
+ + + + + + + + + + + + {{- range .subscribers -}} + + + + + {{- end -}} + +
Subscribers for {{ .list_name }}
EmailActions
{{ .email }} + {{- if $.direct_access -}} + + + + + + {{- end -}} +
+
+ {{- if .direct_access -}} +
+
+ + + + + + + + + + + + + + + + + + +
Add subscriber
Email
+
+
+
+ +
+
+
+
+
+ {{- end -}} +
+ + + +{{- end -}} diff --git a/sql/schema.sql b/sql/schema.sql index 92ae605a995fb7dcaa28b35b062c5f36d3eac4b7..7805377e692f0c83b0babfe92b6920e39b683569 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -36,6 +36,13 @@ date TIMESTAMP NOT NULL, 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, -- 2.48.1