diff options
author | Runxi Yu <me@runxiyu.org> | 2025-09-14 22:28:12 +0800 |
---|---|---|
committer | Runxi Yu <me@runxiyu.org> | 2025-09-14 22:28:12 +0800 |
commit | 01e4fd482ebcc827d3c76c00910529abbf666454 (patch) | |
tree | 300156b866864a07a0ed7680e6572ae97c20e325 | |
parent | Update dependencies (diff) | |
download | forge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.gz forge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.zst forge-01e4fd482ebcc827d3c76c00910529abbf666454.zip |
Add basic mailing listspre-refactor
-rw-r--r-- | forge.scfg | 22 | ||||
-rw-r--r-- | forged/internal/bare/unions.go | 6 | ||||
-rw-r--r-- | forged/internal/unsorted/config.go | 9 | ||||
-rw-r--r-- | forged/internal/unsorted/http_handle_group_index.go | 129 | ||||
-rw-r--r-- | forged/internal/unsorted/http_handle_mailing_lists.go | 386 | ||||
-rw-r--r-- | forged/internal/unsorted/http_handle_repo_upload_pack.go | 8 | ||||
-rw-r--r-- | forged/internal/unsorted/http_server.go | 49 | ||||
-rw-r--r-- | forged/internal/unsorted/lmtp_handle_mlist.go | 84 | ||||
-rw-r--r-- | forged/internal/unsorted/lmtp_server.go | 9 | ||||
-rw-r--r-- | forged/internal/unsorted/smtp_relay.go | 185 | ||||
-rw-r--r-- | forged/static/style.css | 10 | ||||
-rw-r--r-- | forged/templates/_group_view.tmpl | 25 | ||||
-rw-r--r-- | forged/templates/group.tmpl | 42 | ||||
-rw-r--r-- | forged/templates/mailing_list.tmpl | 56 | ||||
-rw-r--r-- | forged/templates/mailing_list_message.tmpl | 42 | ||||
-rw-r--r-- | forged/templates/mailing_list_subscribers.tmpl | 82 | ||||
-rw-r--r-- | sql/schema.sql | 7 |
17 files changed, 1098 insertions, 53 deletions
@@ -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, |