landed E-mail subscription delivery

This commit is contained in:
2026-01-29 15:52:24 -07:00
parent a18a7c70f7
commit 184c614163
11 changed files with 229 additions and 4 deletions
+14 -1
View File
@@ -20,6 +20,7 @@ import (
"strings" "strings"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/email"
"git.erbosoft.com/amy/amsterdam/htmlcheck" "git.erbosoft.com/amy/amsterdam/htmlcheck"
"git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/ui"
"github.com/CloudyKit/jet/v6" "github.com/CloudyKit/jet/v6"
@@ -770,7 +771,19 @@ func PostInTopic(ctxt ui.AmContext) (string, any, error) {
return ui.ErrorPage(ctxt, err) 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") { if !ctxt.FormFieldIsSet("attach") {
return "redirect", returnURL, nil // no attachment - just bounce directly to the destination return "redirect", returnURL, nil // no attachment - just bounce directly to the destination
+23 -1
View File
@@ -12,6 +12,7 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -258,7 +259,7 @@ func internalContactInfo(ctx context.Context, id int32) (*ContactInfo, error) {
/* AmGetContactInfo retrieves the contact info for a given identifier. /* AmGetContactInfo retrieves the contact info for a given identifier.
* Parameters: * Parameters:
* ctx - Standard Go context value. * ctx - Standard Go context value.
* id - The contact info ID top retrieve. * id - The contact info ID to retrieve.
* Returns: * Returns:
* ContactInfo retrieved, or nil. * ContactInfo retrieved, or nil.
* Standard Go error status. * Standard Go error status.
@@ -280,6 +281,27 @@ func AmGetContactInfo(ctx context.Context, id int32) (*ContactInfo, error) {
return nil, err 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. /* AmNewUserContactInfo creates a new contact info record for the user.
* Parameters: * Parameters:
* uid - The UID of the owner of this contact info. * uid - The UID of the owner of this contact info.
+19
View File
@@ -240,6 +240,25 @@ func (t *Topic) GetBozos(ctx context.Context, u *User) ([]TopicBozo, error) {
return rc, nil 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. // TopicSettings contains per-user settings for topics, including the "last read" message pointer.
type TopicSettings struct { type TopicSettings struct {
TopicId int32 `db:"topicid"` // unique ID of the topic TopicId int32 `db:"topicid"` // unique ID of the topic
+1 -1
View File
@@ -3,7 +3,7 @@
_(italicized items can be deferred)_ _(italicized items can be deferred)_
- ~~Topics list: Set up Conference permalink~~ - ~~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_ - _Error handling: shift titles and templates for different error codes_
- Find Posts - Find Posts
- Services mechanism: Conference vtable - Services mechanism: Conference vtable
+6
View File
@@ -24,6 +24,7 @@ type Message interface {
AddTo(string, string) AddTo(string, string)
AddCC(string, string) AddCC(string, string)
AddBCC(string, string) AddBCC(string, string)
GetSubject() string
SetSubject(string) SetSubject(string)
SetText(string) SetText(string)
AddHeader(string, string) AddHeader(string, string)
@@ -85,6 +86,11 @@ func (m *amMessage) AddBCC(addr string, name string) {
m.toAddrs = append(m.toAddrs, addr) 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. // SetSubject sets the message's subject.
func (m *amMessage) SetSubject(s string) { func (m *amMessage) SetSubject(s string) {
m.subject = s m.subject = s
+1 -1
View File
@@ -49,7 +49,7 @@ var auth smtp.Auth
// formatMessage takes a message and turns it into serialized bytes for sending. // formatMessage takes a message and turns it into serialized bytes for sending.
func formatMessage(ctx context.Context, m *amMessage) ([]byte, error) { 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. // Render the template for the message, which may reset Subject.
templ, err := emailRenderer.GetTemplate(m.template) templ, err := emailRenderer.GetTemplate(m.template)
if err == nil { if err == nil {
+119
View File
@@ -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)
}
}
}
+8
View File
@@ -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") }} {{ .SetSubject("Amsterdam Email Confirmation") }}
Welcome to the Amsterdam conferencing system! In order to fully activate your 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 account after you register or change your E-mail address, you must provide a
+22
View File
@@ -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.
+8
View File
@@ -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") }} {{ .SetSubject("Amsterdam Password Changed") }}
The password for your account "{{ username }}" has been changed. The new The password for your account "{{ username }}" has been changed. The new
password is "{{ password }}". password is "{{ password }}".
+8
View File
@@ -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") }} {{ .SetSubject("Amsterdam Password Reminder Message") }}
Here is the password reminder for your account "{{ username }}" as you requested: Here is the password reminder for your account "{{ username }}" as you requested: