aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--forged/internal/unsorted/http_handle_mailing_lists.go5
-rw-r--r--forged/internal/unsorted/lmtp_handle_mlist.go113
-rw-r--r--forged/internal/unsorted/lmtp_server.go29
-rw-r--r--forged/templates/mailing_list.tmpl2
-rw-r--r--forged/templates/repo_commit.tmpl5
6 files changed, 140 insertions, 15 deletions
diff --git a/README.md b/README.md
index 94442dd..0ddef8d 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ primarily designed for self-hosting by small organizations and individuals.
* Push to `contrib/` branches to automatically create merge requests
* Basic federated authentication
* Converting mailed patches to branches
+* Basic mailing lists
## Planned features
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 -}}
diff --git a/forged/templates/repo_commit.tmpl b/forged/templates/repo_commit.tmpl
index 42f2bcd..530af2f 100644
--- a/forged/templates/repo_commit.tmpl
+++ b/forged/templates/repo_commit.tmpl
@@ -44,6 +44,11 @@
{{- .repo_description -}}
</div>
</div>
+ <div class="padding-wrapper">
+ <p>
+ Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
+ </p>
+ </div>
<div class="padding-wrapper scroll">
<div class="key-val-grid-wrapper">
<section id="commit-info" class="key-val-grid">