From 98b250df38ca82f08ad0b6ee19eace3dfb182ede Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Thu, 25 Sep 2025 08:23:20 +0800 Subject: [PATCH] Mailing lists: implement sub/unsub --- forged/internal/unsorted/http_handle_mailing_lists.go | 5 ++++- forged/internal/unsorted/lmtp_handle_mlist.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++------ forged/internal/unsorted/lmtp_server.go | 29 +++++++++++++++++++++++++++-- forged/templates/mailing_list.tmpl | 2 ++ diff --git a/forged/internal/unsorted/http_handle_mailing_lists.go b/forged/internal/unsorted/http_handle_mailing_lists.go index 973907818dfc575d56d422d6b9e31b83bb2d3177..e5f71c884142c56524f45f0ef1ad973c1e1931ac 100644 --- a/forged/internal/unsorted/http_handle_mailing_lists.go +++ b/forged/internal/unsorted/http_handle_mailing_lists.go @@ -93,7 +93,10 @@ segments := params["url_segments"].([]string) for _, part := range segments[:params["separator_index"].(int)+3] { listURLRoot += part + "/" } - params["list_email_address"] = listURLRoot[1:len(listURLRoot)-1] + "@" + s.config.LMTP.Domain + localPart := listURLRoot[1 : len(listURLRoot)-1] + params["list_email_address"] = localPart + "@" + s.config.LMTP.Domain + params["list_subscribe_address"] = listURLRoot[1:] + "subscribe@" + s.config.LMTP.Domain + params["list_unsubscribe_address"] = listURLRoot[1:] + "unsubscribe@" + s.config.LMTP.Domain var count int if err := s.database.QueryRow(request.Context(), ` diff --git a/forged/internal/unsorted/lmtp_handle_mlist.go b/forged/internal/unsorted/lmtp_handle_mlist.go index 321d65dfc386a2022bac8c64532fb74fb87cb731..ea5c378214b19fc2f58e9966c90dafdda4abd88e 100644 --- a/forged/internal/unsorted/lmtp_handle_mlist.go +++ b/forged/internal/unsorted/lmtp_handle_mlist.go @@ -6,7 +6,9 @@ import ( "context" "errors" + "log/slog" "net/mail" + "strings" "time" "github.com/emersion/go-message" @@ -18,19 +20,8 @@ // for the specified group/list. It expects the list to be already existing. func (s *Server) lmtpHandleMailingList(session *lmtpSession, groupPath []string, listName string, email *message.Entity, raw []byte, envelopeFrom string) error { ctx := session.ctx - groupID, err := s.resolveGroupPath(ctx, groupPath) + _, listID, err := s.resolveMailingList(ctx, groupPath, listName) if err != nil { - return err - } - - var listID int - if err := s.database.QueryRow(ctx, - `SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`, - groupID, listName, - ).Scan(&listID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return errors.New("mailing list not found") - } return err } @@ -56,6 +47,104 @@ return derr } return nil +} + +// lmtpHandleMailingListSubscribe subscribes the envelope sender to the mailing list without +// any additional confirmation. +func (s *Server) lmtpHandleMailingListSubscribe(session *lmtpSession, groupPath []string, listName string, envelopeFrom string) error { + ctx := session.ctx + _, listID, err := s.resolveMailingList(ctx, groupPath, listName) + if err != nil { + return err + } + + address, err := normalizeEnvelopeAddress(envelopeFrom) + if err != nil { + return err + } + + if _, err := s.database.Exec(ctx, + `INSERT INTO mailing_list_subscribers (list_id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + listID, address, + ); err != nil { + return err + } + + slog.Info("mailing list subscription", + "group", strings.Join(groupPath, "/"), + "list", listName, + "email", address, + ) + + return nil +} + +// lmtpHandleMailingListUnsubscribe removes the envelope sender from the mailing list. +func (s *Server) lmtpHandleMailingListUnsubscribe(session *lmtpSession, groupPath []string, listName string, envelopeFrom string) error { + ctx := session.ctx + _, listID, err := s.resolveMailingList(ctx, groupPath, listName) + if err != nil { + return err + } + + address, err := normalizeEnvelopeAddress(envelopeFrom) + if err != nil { + return err + } + + if _, err := s.database.Exec(ctx, + `DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND email = $2`, + listID, address, + ); err != nil { + return err + } + + slog.Info("mailing list unsubscription", + "group", strings.Join(groupPath, "/"), + "list", listName, + "email", address, + ) + + return nil +} + +func (s *Server) resolveMailingList(ctx context.Context, groupPath []string, listName string) (int, int, error) { + groupID, err := s.resolveGroupPath(ctx, groupPath) + if err != nil { + return 0, 0, err + } + + var listID int + if err := s.database.QueryRow(ctx, + `SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`, + groupID, listName, + ).Scan(&listID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, 0, errors.New("mailing list not found") + } + return 0, 0, err + } + + return groupID, listID, nil +} + +func normalizeEnvelopeAddress(envelopeFrom string) (string, error) { + envelopeFrom = strings.TrimSpace(envelopeFrom) + if envelopeFrom == "" || envelopeFrom == "<>" { + return "", errors.New("envelope sender required") + } + addr, err := mail.ParseAddress(envelopeFrom) + if err != nil { + trimmed := strings.Trim(envelopeFrom, "<>") + if trimmed == "" { + return "", errors.New("envelope sender required") + } + if !strings.Contains(trimmed, "@") { + return "", err + } + return strings.ToLower(trimmed), nil + } + return strings.ToLower(addr.Address), nil } // resolveGroupPath resolves a group path (segments) to a group ID. diff --git a/forged/internal/unsorted/lmtp_server.go b/forged/internal/unsorted/lmtp_server.go index e1f3cab06121f7cbff94ad3ef77bc839c5129e28..4e92c23260dd188cbdedeae0ae9c4c4a2ed54bfb 100644 --- a/forged/internal/unsorted/lmtp_server.go +++ b/forged/internal/unsorted/lmtp_server.go @@ -185,8 +185,33 @@ slog.Error("error handling patch", "error", err) goto end } case "lists": - if err = session.s.lmtpHandleMailingList(session, groupPath, moduleName, email, data, from); err != nil { - slog.Error("error handling mailing list message", "error", err) + var moduleAction string + if len(segments) > sepIndex+3 { + moduleAction = segments[sepIndex+3] + if len(segments) > sepIndex+4 { + err = errors.New("too many path segments for mailing list command") + goto end + } + } + + switch moduleAction { + case "": + if err = session.s.lmtpHandleMailingList(session, groupPath, moduleName, email, data, from); err != nil { + slog.Error("error handling mailing list message", "error", err) + goto end + } + case "subscribe": + if err = session.s.lmtpHandleMailingListSubscribe(session, groupPath, moduleName, from); err != nil { + slog.Error("error handling mailing list subscribe", "error", err) + goto end + } + case "unsubscribe": + if err = session.s.lmtpHandleMailingListUnsubscribe(session, groupPath, moduleName, from); err != nil { + slog.Error("error handling mailing list unsubscribe", "error", err) + goto end + } + default: + err = fmt.Errorf("unsupported mailing list command: %q", moduleAction) goto end } default: diff --git a/forged/templates/mailing_list.tmpl b/forged/templates/mailing_list.tmpl index 9144253e52c30a7cb006dc9c4fbca231c5087d47..10d3a3a9c600a70b345c9454a52f272d75f76f85 100644 --- a/forged/templates/mailing_list.tmpl +++ b/forged/templates/mailing_list.tmpl @@ -18,6 +18,8 @@ {{- if .list_description -}}

{{ .list_description }}

{{- end -}}

Address: {{ .list_email_address }}

+

Subscribe: {{ .list_subscribe_address }}

+

Unsubscribe: {{ .list_unsubscribe_address }}

{{- if .direct_access -}}

Manage subscribers

{{- end -}} -- 2.48.1