diff options
-rw-r--r-- | forged/internal/unsorted/http_handle_mailing_lists.go | 5 | ||||
-rw-r--r-- | forged/internal/unsorted/lmtp_handle_mlist.go | 113 | ||||
-rw-r--r-- | forged/internal/unsorted/lmtp_server.go | 29 | ||||
-rw-r--r-- | forged/templates/mailing_list.tmpl | 2 |
4 files changed, 134 insertions, 15 deletions
diff --git a/forged/internal/unsorted/http_handle_mailing_lists.go b/forged/internal/unsorted/http_handle_mailing_lists.go index 9739078..e5f71c8 100644 --- a/forged/internal/unsorted/http_handle_mailing_lists.go +++ b/forged/internal/unsorted/http_handle_mailing_lists.go @@ -93,7 +93,10 @@ func (s *Server) httpHandleMailingListIndex(writer http.ResponseWriter, request 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 321d65d..ea5c378 100644 --- a/forged/internal/unsorted/lmtp_handle_mlist.go +++ b/forged/internal/unsorted/lmtp_handle_mlist.go @@ -6,7 +6,9 @@ package unsorted import ( "context" "errors" + "log/slog" "net/mail" + "strings" "time" "github.com/emersion/go-message" @@ -18,22 +20,11 @@ import ( 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 - } - title := email.Header.Get("Subject") sender := email.Header.Get("From") @@ -58,6 +49,104 @@ func (s *Server) lmtpHandleMailingList(session *lmtpSession, groupPath []string, 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. func (s *Server) resolveGroupPath(ctx context.Context, groupPath []string) (int, error) { var groupID int diff --git a/forged/internal/unsorted/lmtp_server.go b/forged/internal/unsorted/lmtp_server.go index e1f3cab..4e92c23 100644 --- a/forged/internal/unsorted/lmtp_server.go +++ b/forged/internal/unsorted/lmtp_server.go @@ -185,8 +185,33 @@ func (session *lmtpSession) Data(r io.Reader) error { 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 9144253..10d3a3a 100644 --- a/forged/templates/mailing_list.tmpl +++ b/forged/templates/mailing_list.tmpl @@ -18,6 +18,8 @@ <p>{{ .list_description }}</p> {{- end -}} <p><strong>Address:</strong> <code>{{ .list_email_address }}</code></p> + <p><strong>Subscribe:</strong> <code>{{ .list_subscribe_address }}</code></p> + <p><strong>Unsubscribe:</strong> <code>{{ .list_unsubscribe_address }}</code></p> {{- if .direct_access -}} <p><a href="subscribers/">Manage subscribers</a></p> {{- end -}} |