From 184c614163aa21e862e077a686a84b973052238d Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Thu, 29 Jan 2026 15:52:24 -0700 Subject: [PATCH] landed E-mail subscription delivery --- conference.go | 15 +++- database/contactinfo.go | 24 +++++- database/topic.go | 19 +++++ docs/MISSINGFUNCS.md | 2 +- email/message.go | 6 ++ email/sender.go | 2 +- email/subscription.go | 119 ++++++++++++++++++++++++++++++ email/templates/email_confirm.jet | 8 ++ email/templates/mailpost.jet | 22 ++++++ email/templates/pass_change.jet | 8 ++ email/templates/pass_remind.jet | 8 ++ 11 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 email/subscription.go create mode 100644 email/templates/mailpost.jet diff --git a/conference.go b/conference.go index ba120e8..6202892 100644 --- a/conference.go +++ b/conference.go @@ -20,6 +20,7 @@ import ( "strings" "git.erbosoft.com/amy/amsterdam/database" + "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/htmlcheck" "git.erbosoft.com/amy/amsterdam/ui" "github.com/CloudyKit/jet/v6" @@ -770,7 +771,19 @@ func PostInTopic(ctxt ui.AmContext) (string, any, error) { return ui.ErrorPage(ctxt, err) } - // TODO: whoever's subscribed needs to get a copy of this post in their E-mail + // Check who's subscribed to this topic. + subs, err := topic.GetSubscribers(ctxt.Ctx()) + if err != nil { + log.Errorf("unable to deliver message to subscribers: %v", err) + } else if len(subs) > 0 { + // kick off a task to compose E-mails and deliver them to everyone + alias := ctxt.GetScratch("currentAlias").(string) + poster := ctxt.CurrentUser() + ipaddr := ctxt.RemoteIP() + ampool.Submit(func(ctx context.Context) { + email.AmDeliverSubscription(ctx, comm, conf, alias, topic, poster, hdr, postText, subs, ipaddr) + }) + } if !ctxt.FormFieldIsSet("attach") { return "redirect", returnURL, nil // no attachment - just bounce directly to the destination diff --git a/database/contactinfo.go b/database/contactinfo.go index 15d5732..ca77169 100644 --- a/database/contactinfo.go +++ b/database/contactinfo.go @@ -12,6 +12,7 @@ package database import ( "context" "database/sql" + "errors" "fmt" "strings" "sync" @@ -258,7 +259,7 @@ func internalContactInfo(ctx context.Context, id int32) (*ContactInfo, error) { /* AmGetContactInfo retrieves the contact info for a given identifier. * Parameters: * ctx - Standard Go context value. - * id - The contact info ID top retrieve. + * id - The contact info ID to retrieve. * Returns: * ContactInfo retrieved, or nil. * Standard Go error status. @@ -280,6 +281,27 @@ func AmGetContactInfo(ctx context.Context, id int32) (*ContactInfo, error) { return nil, err } +/* AmGetContactInfoForUser retrieves the contact info for a given user ID. + * Parameters: + * ctx - Standard Go context value. + * uid - The UID to get the contact info for. + * Returns: + * ContactInfo retrieved, or nil. + * Standard Go error status. + */ +func AmGetContactInfoForUser(ctx context.Context, uid int32) (*ContactInfo, error) { + row := amdb.QueryRowContext(ctx, "SELECT contactid FROM contacts WHERE owner_uid = ? AND owner_commid = -1", uid) + var cid int32 + err := row.Scan(&cid) + switch err { + case nil: + return AmGetContactInfo(ctx, cid) + case sql.ErrNoRows: + return nil, errors.New("contact not found") + } + return nil, err +} + /* AmNewUserContactInfo creates a new contact info record for the user. * Parameters: * uid - The UID of the owner of this contact info. diff --git a/database/topic.go b/database/topic.go index 2a6f7fb..2e7d111 100644 --- a/database/topic.go +++ b/database/topic.go @@ -240,6 +240,25 @@ func (t *Topic) GetBozos(ctx context.Context, u *User) ([]TopicBozo, error) { return rc, nil } +// GetSubscribers returns an array of UIDs of every user that subscribed to the topic. +func (t *Topic) GetSubscribers(ctx context.Context) ([]int32, error) { + rs, err := amdb.QueryContext(ctx, "SELECT uid FROM topicsettings WHERE topicid = ? AND subscribe <> 0", t.TopicId) + if err != nil { + return nil, err + } + rc := make([]int32, 0) + for rs.Next() { + var tmp int32 + err = rs.Scan(&tmp) + if err == nil { + rc = append(rc, tmp) + } else { + break + } + } + return rc, err +} + // TopicSettings contains per-user settings for topics, including the "last read" message pointer. type TopicSettings struct { TopicId int32 `db:"topicid"` // unique ID of the topic diff --git a/docs/MISSINGFUNCS.md b/docs/MISSINGFUNCS.md index 3ba6f91..c57ff35 100644 --- a/docs/MISSINGFUNCS.md +++ b/docs/MISSINGFUNCS.md @@ -3,7 +3,7 @@ _(italicized items can be deferred)_ - ~~Topics list: Set up Conference permalink~~ -- Send out E-mails to topic subscribers when a post is made +- ~~Send out E-mails to topic subscribers when a post is made~~ - _Error handling: shift titles and templates for different error codes_ - Find Posts - Services mechanism: Conference vtable diff --git a/email/message.go b/email/message.go index 95b87a6..4d096c3 100644 --- a/email/message.go +++ b/email/message.go @@ -24,6 +24,7 @@ type Message interface { AddTo(string, string) AddCC(string, string) AddBCC(string, string) + GetSubject() string SetSubject(string) SetText(string) AddHeader(string, string) @@ -85,6 +86,11 @@ func (m *amMessage) AddBCC(addr string, name string) { m.toAddrs = append(m.toAddrs, addr) } +// GetSubject gets the message's subject. +func (m *amMessage) GetSubject() string { + return m.subject +} + // SetSubject sets the message's subject. func (m *amMessage) SetSubject(s string) { m.subject = s diff --git a/email/sender.go b/email/sender.go index 6367f62..acccce9 100644 --- a/email/sender.go +++ b/email/sender.go @@ -49,7 +49,7 @@ var auth smtp.Auth // formatMessage takes a message and turns it into serialized bytes for sending. func formatMessage(ctx context.Context, m *amMessage) ([]byte, error) { - if m.template != "" { + if m.template != "" && m.text == "" { // Render the template for the message, which may reset Subject. templ, err := emailRenderer.GetTemplate(m.template) if err == nil { diff --git a/email/subscription.go b/email/subscription.go new file mode 100644 index 0000000..654b26d --- /dev/null +++ b/email/subscription.go @@ -0,0 +1,119 @@ +/* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// Package email contains support for E-mail messages sent by Amsterdam. +package email + +import ( + "bytes" + "context" + + "git.erbosoft.com/amy/amsterdam/database" + "git.erbosoft.com/amy/amsterdam/htmlcheck" + "github.com/CloudyKit/jet/v6" + log "github.com/sirupsen/logrus" +) + +/* AmDeliverSubscription takes a message that's just been posted to a topic and sends it to the list of subscribers + * to that topic. It's intended to be executed in a worker pool task. + * Parameters: + * ctx - Standard Go context parameter. + * comm - Community the message was posted to. + * conf - Conference the message was posted to. + * confAlias - Current alias for that conference. + * topic - Current topic the message was posted to. + * poster - User that posted the message. + * post - The message that was posted. + * text - The text of that post. + * recipUids - Array of user IDs representing recipients of E-mail messages. + * ipaddr - IP address of the poster, used for mail tracing. + */ +func AmDeliverSubscription(ctx context.Context, comm *database.Community, conf *database.Conference, confAlias string, + topic *database.Topic, poster *database.User, post *database.PostHeader, text string, recipUids []int32, ipaddr string) { + log.Debugf("AmDeliverSubscription kicked off by %s with %d mail(s) to deliver", poster.Username, len(recipUids)) + + // Preprocess the text and the pseud. + checker, err := htmlcheck.AmNewHTMLChecker(ctx, "mail-post") + if err != nil { + log.Errorf("AmDeliverSubscription: failed to get HTML checker (%v)", err) + return + } + var realText string + err = checker.Append(text) + if err == nil { + err = checker.Finish() + if err == nil { + realText, err = checker.Value() + } + } + if err != nil { + log.Errorf("AmDeliverSubscription: failed to process post text (%v)", err) + return + } + checker.Reset() + var realPseud string + if post.Pseud != nil { + err = checker.Append(*post.Pseud) + if err == nil { + err = checker.Finish() + if err == nil { + realPseud, err = checker.Value() + } + } + } else { + realPseud = "" + } + if err != nil { + log.Errorf("AmDeliverSubscription: failed to process post pseud (%v)", err) + return + } + + // Use Jet to format the message directly. We bypass the regular formatter in formatMessage in sender.go because + // we don't want to have to format the message once per recipient. + templ, err := emailRenderer.GetTemplate("mailpost.jet") + if err != nil { + log.Errorf("AmDeliverSubscription: failed to load template (%v)", err) + return + } + vars := make(jet.VarMap) + vars.Set("userName", poster.Username) + vars.Set("communityName", comm.Name) + vars.Set("conferenceName", conf.Name) + vars.Set("topicName", topic.Name) + pl := database.AmCreatePostLinkContext(comm.Alias, confAlias, topic.Number) + vars.Set("topicLink", pl.AsString()) + vars.Set("pseud", realPseud) + vars.Set("text", realText) + subjectSink := AmNewEmailMessage(0, "") + var textBuf bytes.Buffer + err = templ.Execute(&textBuf, vars, subjectSink) + if err != nil { + log.Errorf("AmDeliverSubscription: failed to format template (%v)", err) + return + } + sendText := textBuf.String() + + // The delivery loop; build each message and send it. Note that sending a message puts the Message structure on + // the sender goroutine channel, so we have to create a new Message each time, unlike what we did in Venice. + for i := range recipUids { + err = ctx.Err() + if err != nil { + log.Errorf("AmDeliverSubscription: aborted on send loop iter %d with %v", i+1, err) + } + if ci, err := database.AmGetContactInfoForUser(ctx, recipUids[i]); err == nil { + msg := AmNewEmailMessage(poster.Uid, ipaddr) + msg.SetSubject(subjectSink.GetSubject()) + msg.SetText(sendText) + msg.AddTo(*ci.Email, ci.FullName(false)) + msg.Send() + } else { + log.Warnf("AmDeliverSubscription skipped uid %d because no contact info retrieved (%v)", recipUids[i], err) + } + } +} diff --git a/email/templates/email_confirm.jet b/email/templates/email_confirm.jet index 54d959d..fbd87a6 100644 --- a/email/templates/email_confirm.jet +++ b/email/templates/email_confirm.jet @@ -1,3 +1,11 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} {{ .SetSubject("Amsterdam Email Confirmation") }} Welcome to the Amsterdam conferencing system! In order to fully activate your account after you register or change your E-mail address, you must provide a diff --git a/email/templates/mailpost.jet b/email/templates/mailpost.jet new file mode 100644 index 0000000..cb6441c --- /dev/null +++ b/email/templates/mailpost.jet @@ -0,0 +1,22 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} +{{ .SetSubject("New Post in " + topicName)}} +The following message was just posted by "{{ userName }}" to the +"{{ topicName | raw }}" topic in the "{{ conferenceName }}" conference +of the "{{ communityName }}" community on the Amsterdam community system. +-------------------------------------------------------------------------- +{{ pseud | raw }} + +{{ text | raw }} +-------------------------------------------------------------------------- +Join the ongoing discussion at: +{{ GlobalConfig.Site.BaseURL }}/go/{{ topicLink }} + +To stop receiving new posts in this topic by E-mail, visit the above URL, +click the "Manage" button, and click the "Stop Subscribing To This Topic" link. diff --git a/email/templates/pass_change.jet b/email/templates/pass_change.jet index 802d8fb..dd071d4 100644 --- a/email/templates/pass_change.jet +++ b/email/templates/pass_change.jet @@ -1,3 +1,11 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} {{ .SetSubject("Amsterdam Password Changed") }} The password for your account "{{ username }}" has been changed. The new password is "{{ password }}". diff --git a/email/templates/pass_remind.jet b/email/templates/pass_remind.jet index 5ecb4b0..7afbc69 100644 --- a/email/templates/pass_remind.jet +++ b/email/templates/pass_remind.jet @@ -1,3 +1,11 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} {{ .SetSubject("Amsterdam Password Reminder Message") }} Here is the password reminder for your account "{{ username }}" as you requested: