Compare commits

..
Author SHA1 Message Date
codegirl007 97946ba737 Add Discord inbound replies (#15)
Stacks on #14. Mapped Discord admins can reply in a known thread; those messages become site posts and email the homeowner. The bot does not publish a site event, so the reply is not posted back to Discord. Photo attachments are ignored until the upload pipeline lands on this branch.

Reviewed-on: #15
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 18:24:40 +00:00
12 changed files with 152 additions and 447 deletions
+4 -4
View File
@@ -37,15 +37,14 @@ func main() {
log.Fatalf("mail: %v", err)
}
bus := events.New()
mail.Subscribe(bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db))
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db), notifier)
if err != nil {
log.Fatalf("discord: %v", err)
}
if bot != nil {
defer bot.Close()
}
handler := newHandler(db, sessions, uploader, bus)
handler := newHandler(db, sessions, uploader, notifier, bus)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -69,12 +68,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, bus events.Publisher) http.Handler {
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier, bus events.Publisher) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
Mail: notifier,
Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
})
+4 -8
View File
@@ -9,7 +9,7 @@ import (
// API is the Discord REST surface used by the outbound subscriber.
type API interface {
SendToChannel(ctx context.Context, channelID string, msg Message) (messageID string, err error)
StartThread(ctx context.Context, channelID, name string) (threadID string, err error)
StartThread(ctx context.Context, channelID, messageID, name string) (threadID string, err error)
SendToThread(ctx context.Context, threadID string, msg Message) (messageID string, err error)
Edit(ctx context.Context, channelID, messageID string, msg Message) error
Close() error
@@ -27,10 +27,9 @@ func (s *sessionAPI) SendToChannel(_ context.Context, channelID string, msg Mess
return sent.ID, nil
}
func (s *sessionAPI) StartThread(_ context.Context, channelID, name string) (string, error) {
thread, err := s.session.ThreadStartComplex(channelID, &discordgo.ThreadStart{
func (s *sessionAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
thread, err := s.session.MessageThreadStartComplex(channelID, messageID, &discordgo.ThreadStart{
Name: name,
Type: discordgo.ChannelTypeGuildPublicThread,
AutoArchiveDuration: 10080,
})
if err != nil {
@@ -44,12 +43,10 @@ func (s *sessionAPI) SendToThread(ctx context.Context, threadID string, msg Mess
}
func (s *sessionAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
content := messageContent(msg)
embeds := toEmbeds(msg)
_, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
ID: messageID,
Channel: channelID,
Content: &content,
Embeds: &embeds,
})
return err
@@ -64,7 +61,6 @@ func (s *sessionAPI) Close() error {
func toMessageSend(msg Message) *discordgo.MessageSend {
return &discordgo.MessageSend{
Content: messageContent(msg),
Embeds: toEmbeds(msg),
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
@@ -73,7 +69,7 @@ func toMessageSend(msg Message) *discordgo.MessageSend {
func toEmbeds(msg Message) []*discordgo.MessageEmbed {
main := &discordgo.MessageEmbed{
Title: msg.Title,
URL: publicURL(msg.URL),
URL: msg.URL,
Description: msg.Description,
Color: embedColor,
}
+14 -18
View File
@@ -13,6 +13,7 @@ import (
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -24,7 +25,7 @@ type Bot struct {
links store.DiscordLinkStore
api API
store store.Store
bus events.Publisher
mail mail.Notifier
admins map[string]string
botUserID string
}
@@ -35,7 +36,7 @@ func New(channelID string, links store.DiscordLinkStore, api API) *Bot {
}
// FromEnv builds a bot when Discord env is set. Missing config is a no-op.
func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store) (*Bot, error) {
func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store, mailer mail.Notifier) (*Bot, error) {
token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN"))
channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID"))
if token == "" && channelID == "" {
@@ -55,9 +56,12 @@ func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store) (*Bo
return nil, err
}
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
if mailer == nil {
mailer = mail.Nop{}
}
bot := New(channelID, links, &sessionAPI{session: session})
bot.store = st
bot.bus = bus
bot.mail = mailer
bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP"))
session.AddHandler(bot.onMessageCreate)
if bus != nil {
@@ -98,14 +102,6 @@ func (b *Bot) Handle(_ context.Context, ev any) {
}
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
_, err := b.links.GetByPostID(ctx, ev.PostID)
if err == nil {
return
}
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: load link %s: %v", ev.PostID, err)
return
}
if isRoot(ev) {
b.createRoot(ctx, ev)
return
@@ -137,16 +133,16 @@ func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatMessage(ev)
threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
return
}
messageID, err := b.api.SendToThread(ctx, threadID, msg)
messageID, err := b.api.SendToChannel(ctx, b.channelID, msg)
if err != nil {
log.Printf("discord: send root %s: %v", ev.PostID, err)
return
}
threadID, err := b.api.StartThread(ctx, b.channelID, messageID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
MessageID: messageID,
@@ -189,7 +185,7 @@ func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {
if strings.TrimSpace(link.ThreadID) != "" {
return link.ThreadID, nil
return b.channelID, nil
}
root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil {
+17 -61
View File
@@ -2,11 +2,11 @@ package discord
import (
"context"
"strconv"
"strings"
"sync"
"testing"
"strconv"
"plumber/internal/events"
"plumber/internal/store"
)
@@ -30,18 +30,17 @@ func (f *fakeAPI) SendToChannel(_ context.Context, channelID string, msg Message
return f.record("channel", channelID, "", msg)
}
func (f *fakeAPI) StartThread(_ context.Context, channelID, name string) (string, error) {
func (f *fakeAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.next++
id := "thread-" + strconv.Itoa(f.next)
f.sends = append(f.sends, recordedSend{
Kind: "thread",
ChannelID: channelID,
Name: name,
Msg: Message{ThreadName: name},
Msg: Message{ThreadName: name, URL: messageID},
})
return id, nil
return "thread-" + messageID, nil
}
func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) {
@@ -94,21 +93,17 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
}
bot.Handle(ctx, events.PostCreated{PostEvent: root})
if len(api.sends) != 2 ||
api.sends[0].Kind != "thread" ||
api.sends[1].Kind != "thread-msg" {
if len(api.sends) != 2 || api.sends[0].Kind != "channel" || api.sends[1].Kind != "thread" {
t.Fatalf("root sends = %+v", api.sends)
}
if api.sends[0].ChannelID != "channel-1" ||
api.sends[0].Name != "sam asks: Leaky sink" ||
api.sends[1].ChannelID != "thread-1" {
if api.sends[0].ChannelID != "channel-1" || api.sends[1].Name != "Leaky sink" {
t.Fatalf("root routing = %+v", api.sends)
}
if got := api.sends[1].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" {
if got := api.sends[0].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" {
t.Fatalf("root images = %v", got)
}
link, err := links.GetByPostID(ctx, "root-1")
if err != nil || link.MessageID != "msg-2" || link.ThreadID != "thread-1" {
if err != nil || link.MessageID != "msg-1" || link.ThreadID != "thread-msg-1" {
t.Fatalf("root link = %+v, %v", link, err)
}
@@ -121,7 +116,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1",
}
bot.Handle(ctx, events.PostCreated{PostEvent: reply})
if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-1" {
if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-msg-1" {
t.Fatalf("reply sends = %+v", api.sends)
}
replyLink, err := links.GetByPostID(ctx, "reply-1")
@@ -131,7 +126,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
root.Body = "Updated leak."
bot.Handle(ctx, events.PostUpdated{PostEvent: root})
if len(api.edits) != 1 || api.edits[0].ChannelID != "thread-1" || api.edits[0].Name != "msg-2" {
if len(api.edits) != 1 || api.edits[0].ChannelID != "channel-1" || api.edits[0].Name != "msg-1" {
t.Fatalf("root edit = %+v", api.edits)
}
if api.edits[0].Msg.Description != "Updated leak." {
@@ -140,7 +135,7 @@ func TestOutboundRootReplyAndEdit(t *testing.T) {
reply.Body = "Use a ceramic cartridge."
bot.Handle(ctx, events.PostUpdated{PostEvent: reply})
if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-1" || api.edits[1].Name != "msg-3" {
if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-msg-1" || api.edits[1].Name != "msg-3" {
t.Fatalf("reply edit = %+v", api.edits)
}
}
@@ -161,36 +156,6 @@ func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) {
}
}
func TestOutboundSkipsAlreadyLinkedPost(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "reply-1",
MessageID: "d-reply-1",
}); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "root-1",
MessageID: "d-root",
ThreadID: "thread-1",
}); err != nil {
t.Fatal(err)
}
bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: "reply-1",
RootID: "root-1",
ParentID: "root-1",
Body: "Already on Discord.",
}})
if len(api.sends) != 0 {
t.Fatalf("echoed already-linked reply: %+v", api.sends)
}
}
func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
t.Parallel()
@@ -225,30 +190,21 @@ func TestFormatMessage(t *testing.T) {
got.City != "Oakland" ||
got.Author != "sam" ||
got.URL != "https://example.com/q" ||
got.ThreadName != "sam asks: Leaky sink" ||
got.ThreadName != "Leaky sink" ||
len(got.ImageURLs) != 1 {
t.Fatalf("format = %+v", got)
}
reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""})
if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Someone asks: Question" {
if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Question" {
t.Fatalf("reply format = %+v", reply)
}
content := messageContent(got)
if strings.Contains(content, "Leaky sink") ||
!strings.Contains(content, "It drips.") ||
!strings.Contains(content, "Oakland") {
t.Fatalf("content = %q", content)
}
if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" {
t.Fatal("localhost or relative permalink should not be an embed URL")
}
}
func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil, nil)
bot, err := FromEnv(newMemoryLinks(), nil, nil, nil)
if err != nil || bot != nil {
t.Fatalf("disabled FromEnv = (%v, %v)", bot, err)
}
@@ -257,12 +213,12 @@ func TestFromEnvDisabled(t *testing.T) {
func TestFromEnvRequiresBoth(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "token")
t.Setenv("DISCORD_CHANNEL_ID", "")
if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil {
t.Fatal("expected error when channel is missing")
}
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "channel")
if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil {
t.Fatal("expected error when token is missing")
}
}
+4 -40
View File
@@ -39,7 +39,7 @@ func formatMessage(ev events.PostEvent) Message {
Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
City: strings.TrimSpace(ev.City),
Author: author,
ThreadName: threadName(author, ev.Title),
ThreadName: threadName(ev.Title),
}
for _, img := range ev.Images {
url := strings.TrimSpace(img.URL)
@@ -51,16 +51,12 @@ func formatMessage(ev events.PostEvent) Message {
return msg
}
func threadName(author, title string) string {
author = strings.TrimSpace(author)
if author == "" {
author = "Someone"
}
func threadName(title string) string {
title = strings.TrimSpace(title)
if title == "" {
title = "Question"
return "Question"
}
return truncateRunes(author+" asks: "+title, threadNameLimit)
return truncateRunes(title, threadNameLimit)
}
func truncateRunes(s string, max int) string {
@@ -77,35 +73,3 @@ func truncateRunes(s string, max int) string {
func isRoot(ev events.PostEvent) bool {
return strings.TrimSpace(ev.ParentID) == ""
}
func messageContent(msg Message) string {
var parts []string
if body := strings.TrimSpace(msg.Description); body != "" {
parts = append(parts, body)
}
var meta []string
if msg.City != "" {
meta = append(meta, msg.City)
}
if msg.Author != "" {
meta = append(meta, msg.Author)
}
if len(meta) > 0 {
parts = append(parts, strings.Join(meta, " · "))
}
if u := publicURL(msg.URL); u != "" {
parts = append(parts, u)
}
return truncateRunes(strings.Join(parts, "\n"), 2000)
}
func publicURL(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "https://") {
return ""
}
if strings.Contains(raw, "localhost") || strings.Contains(raw, "127.0.0.1") {
return ""
}
return raw
}
+39 -15
View File
@@ -7,10 +7,11 @@ import (
"fmt"
"log"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -126,9 +127,8 @@ func (b *Bot) handleInbound(in inboundMessage) {
MessageID: in.ID,
}); err != nil {
log.Printf("discord: save inbound link %s: %v", reply.ID, err)
return
}
b.publishInbound(reply, root, author)
b.notifyInboundReply(parent, root, reply, author)
log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID)
}
@@ -184,20 +184,44 @@ func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *sto
return post, current, nil
}
func (b *Bot) publishInbound(reply, root *store.Post, author *store.User) {
if b == nil || b.bus == nil || reply == nil || root == nil || author == nil {
func (b *Bot) notifyInboundReply(parent, root, reply *store.Post, author *store.User) {
if parent == nil || root == nil || reply == nil || author == nil || b.mail == nil {
return
}
ev := events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
Body: reply.Body,
AuthorID: author.ID,
AuthorName: author.Name,
AuthorRole: string(author.Role),
if _, disabled := b.mail.(mail.Nop); disabled {
return
}
if reply.ParentID != nil {
ev.ParentID = *reply.ParentID
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
b.bus.Publish(context.Background(), events.PostCreated{PostEvent: ev})
if recipientID == author.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: author.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := b.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := b.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
+5 -6
View File
@@ -27,11 +27,9 @@ func TestInboundCreatesSiteReply(t *testing.T) {
links := newMemoryLinks()
api := &fakeAPI{}
mailer := &mail.Recording{}
bot := inboundTestBot(mem, links, api, admin.Username)
bot := inboundTestBot(mem, links, api, mailer, admin.Username)
bus := events.New()
defer bus.Close()
bot.bus = bus
mail.Subscribe(bus, mem, mailer)
bus.Subscribe(bot.Handle)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
@@ -70,7 +68,7 @@ func TestInboundParentsFromReference(t *testing.T) {
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
bot := inboundTestBot(mem, links, &fakeAPI{}, admin.Username)
bot := inboundTestBot(mem, links, &fakeAPI{}, &mail.Recording{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."}
if err := mem.CreatePost(context.Background(), plumberReply); err != nil {
@@ -108,7 +106,7 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
bot := inboundTestBot(mem, links, api, admin.Username)
bot := inboundTestBot(mem, links, api, mail.Nop{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
hidden := &store.Post{
AuthorID: homeowner.ID,
@@ -150,9 +148,10 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
}
}
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, adminUsername string) *Bot {
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, mailer mail.Notifier, adminUsername string) *Bot {
bot := New("channel-1", links, api)
bot.store = mem
bot.mail = mailer
bot.admins = map[string]string{"snow-admin": adminUsername}
bot.botUserID = "bot-1"
return bot
-89
View File
@@ -1,89 +0,0 @@
package mail
import (
"context"
"log"
"strings"
"time"
"plumber/internal/events"
"plumber/internal/store"
)
// Subscribe sends reply emails from PostCreated events. Nop or nil is a no-op.
func Subscribe(bus *events.Bus, st store.Store, n Notifier) {
if bus == nil || st == nil || n == nil {
return
}
if _, disabled := n.(Nop); disabled {
return
}
s := subscriber{store: st, mail: n}
bus.Subscribe(s.handle)
}
type subscriber struct {
store store.Store
mail Notifier
}
func (s subscriber) handle(_ context.Context, ev any) {
created, ok := ev.(events.PostCreated)
if !ok {
return
}
if strings.TrimSpace(created.ParentID) == "" {
return
}
go s.notifyReply(created.PostEvent)
}
func (s subscriber) notifyReply(ev events.PostEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
parent, err := s.store.GetPost(ctx, ev.ParentID)
if err != nil {
log.Printf("notify reply %s: load parent: %v", ev.PostID, err)
return
}
root, err := s.store.GetPost(ctx, ev.RootID)
if err != nil {
log.Printf("notify reply %s: load root: %v", ev.PostID, err)
return
}
author, err := s.store.UserByID(ctx, ev.AuthorID)
if err != nil {
log.Printf("notify reply %s: load author: %v", ev.PostID, err)
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: ev.PostID,
ReplyBody: ev.Body,
ReplyAuthorName: author.Name,
}
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}
-200
View File
@@ -1,200 +0,0 @@
package mail
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestSubscribeReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
adminReply := seedMailReply(t, mem, admin.ID, root.ID)
homeownerReply := seedMailReply(t, mem, homeowner.ID, adminReply.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, recording)
ctx := context.Background()
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: root.ID,
RootID: root.ID,
Title: root.Title,
Body: root.Body,
AuthorID: homeowner.ID,
}})
bus.Publish(ctx, events.PostUpdated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: "Edited",
AuthorID: admin.ID,
}})
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: adminReply.Body,
AuthorID: admin.ID,
}})
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply = %+v", msg)
}
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: homeownerReply.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: homeownerReply.Body,
AuthorID: homeowner.ID,
}})
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply = %+v", msg)
}
nestedAdmin := seedMailReply(t, mem, admin.ID, adminReply.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: nestedAdmin.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: nestedAdmin.Body,
AuthorID: admin.ID,
}})
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != nestedAdmin.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply = %+v", msg)
}
self := seedMailReply(t, mem, homeowner.ID, root.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: self.ID,
RootID: root.ID,
ParentID: root.ID,
Body: self.Body,
AuthorID: homeowner.ID,
}})
noEmail := seedMailUser(t, mem, "quiet", store.RoleUser, "")
quietRoot := seedMailRoot(t, mem, noEmail.ID, "Quiet thread", "No email.")
quietReply := seedMailReply(t, mem, admin.ID, quietRoot.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: quietReply.ID,
RootID: quietRoot.ID,
ParentID: quietRoot.ID,
Body: quietReply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, root, edit, or no-email sent mail: %+v", recording.Snapshot())
}
}
func TestSubscribeNopIgnoresReplies(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
reply := seedMailReply(t, mem, admin.ID, root.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, Nop{})
Subscribe(nil, mem, recording)
Subscribe(bus, mem, nil)
bus.Publish(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: reply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 0 {
t.Fatalf("Nop or nil subscribe sent mail: %+v", recording.Snapshot())
}
}
func seedMailUser(t *testing.T, mem *store.Memory, username string, role store.Role, email string) *store.User {
t.Helper()
u := &store.User{
Username: username,
Name: username,
Email: email,
PasswordHash: "x",
Role: role,
}
if err := mem.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
return u
}
func seedMailRoot(t *testing.T, mem *store.Memory, authorID, title, body string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: title,
Body: body,
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
return root
}
func seedMailReply(t *testing.T, mem *store.Memory, authorID, parentID string) *store.Post {
t.Helper()
reply := &store.Post{
ParentID: &parentID,
AuthorID: authorID,
Body: "Reply from " + authorID,
}
if err := mem.CreatePost(context.Background(), reply); err != nil {
t.Fatal(err)
}
return reply
}
func waitForMail(t *testing.T, recording *Recording, want int) []PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
+59
View File
@@ -5,12 +5,15 @@ import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -79,6 +82,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil {
root = post
}
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
s.publishPostCreated(post, root, user)
http.Redirect(
w,
@@ -88,6 +94,59 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
)
}
// notifyPostReply emails the root homeowner for admin replies and the direct
// parent author for homeowner replies.
func (s *Server) notifyPostReply(
parent *store.Post,
root *store.Post,
reply *store.Post,
replyAuthor *store.User,
) {
if parent == nil ||
root == nil ||
reply == nil ||
replyAuthor == nil ||
s.cfg.Mail == nil {
return
}
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if replyAuthor.Admin() {
recipientID = root.AuthorID
}
if recipientID == replyAuthor.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
// handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
+1 -6
View File
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
@@ -264,12 +263,8 @@ func TestEditPostRoutePermissions(t *testing.T) {
func TestPostReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
bus := events.New()
defer bus.Close()
recording := &mail.Recording{}
mail.Subscribe(bus, mem, recording)
srv := newTestServerStore(t, mem, Config{Events: bus})
srv, mem := newTestServer(t, Config{Mail: recording})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
+5
View File
@@ -22,6 +22,7 @@ import (
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -34,6 +35,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet
Blob blob.Uploader
Mail mail.Notifier
Events events.Publisher
BaseURL string
}
@@ -108,6 +110,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
}
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
if cfg.Events == nil {
cfg.Events = events.Nop{}
}