diff --git a/conferenceadmin.go b/conferenceadmin.go index 0d79ffb..de59aed 100644 --- a/conferenceadmin.go +++ b/conferenceadmin.go @@ -11,12 +11,15 @@ package main import ( + "context" "errors" "fmt" "strconv" "strings" + "time" "git.erbosoft.com/amy/amsterdam/database" + "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" log "github.com/sirupsen/logrus" @@ -514,6 +517,13 @@ func ConfReports(ctxt ui.AmContext) (string, any) { } } +/* ConferenceEmailForm displays the dialog for E-mailing participants in a conference. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ func ConferenceEmailForm(ctxt ui.AmContext) (string, any) { comm := ctxt.CurrentCommunity() conf := ctxt.GetScratch("currentConference").(*database.Conference) @@ -535,6 +545,115 @@ func ConferenceEmailForm(ctxt ui.AmContext) (string, any) { return "framed", "conf_email.jet" } +/* ConferenceEmail sends E-mail to participants in a conference. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func ConferenceEmail(ctxt ui.AmContext) (string, any) { + comm := ctxt.CurrentCommunity() + conf := ctxt.GetScratch("currentConference").(*database.Conference) + myLevel := ctxt.GetScratch("levelInConference").(uint16) + if !conf.TestPermission("Conference.EMailParticipants", myLevel) { + return "error", ENOPERM + } + + // Handle button presses. + if ctxt.FormFieldIsSet("cancel") { + return "redirect", fmt.Sprintf("/comm/%s/conf/%s/manage", comm.Alias, ctxt.GetScratch("currentAlias")) + } else if !ctxt.FormFieldIsSet("send") { + return "error", EBUTTON + } + + // extract user selector + porl := ctxt.FormField("porl") + var userSelect int + switch porl { + case "0": + userSelect = database.ActiveUserPosters + case "1": + userSelect = database.ActiveUserReaders + default: + return "error", EINVAL + } + + // extract number of days + days := -1 + if ctxt.FormFieldIsSet("xday") { + var err error + days, err = ctxt.FormFieldInt("day") + if err != nil { + return "error", err + } else if days <= 0 { + return "error", "Invalid number of days specified" + } + } + + // extract list of recipients and other needed data + var recipients []string + templateName := "" + topicName := "" + top, err := ctxt.FormFieldInt("top") + if err != nil { + return "error", err + } + if top == 0 { + recipients, err = conf.GetActiveUserEMailAddrs(ctxt.Ctx(), userSelect, days) + if userSelect == database.ActiveUserPosters { + templateName = "conf_mass_poster.jet" + } else { + templateName = "conf_mass_reader.jet" + } + } else { + var topic *database.Topic + if topic, err = database.AmGetTopicByNumber(ctxt.Ctx(), conf, int16(top)); err == nil { + recipients, err = topic.GetActiveUserEMailAddrs(ctxt.Ctx(), userSelect, days) + topicName = topic.Name + } + if userSelect == database.ActiveUserPosters { + templateName = "topic_mass_poster.jet" + } else { + templateName = "topic_mass_reader.jet" + } + } + if err != nil { + return "error", err + } + + // Kick off a background task to send all the E-mail messages. + subj := ctxt.FormField("subj") + pb := ctxt.FormField("pb") + confName := conf.Name + commName := comm.Name + myUID := ctxt.CurrentUserId() + myIP := ctxt.RemoteIP() + log.Infof("ConferenceEmail: About to send mass E-mail to %d recipients", len(recipients)) + ampool.Submit(func(ctx context.Context) { + start := time.Now() + for _, addr := range recipients { + err := ctx.Err() + if err != nil { + break + } + msg := email.AmNewEmailMessage(myUID, myIP) + msg.AddTo(addr, "") + msg.SetSubject(subj) + msg.SetTemplate(templateName) + msg.AddVariable("text", pb) + msg.AddVariable("topicName", topicName) + msg.AddVariable("confName", confName) + msg.AddVariable("commName", commName) + msg.Send() + } + elapsed := time.Since(start) + log.Infof("ConferenceEmail delivery completed in %s", elapsed) + }) + + return "redirect", fmt.Sprintf("/comm/%s/conf/%s/manage", comm.Alias, ctxt.GetScratch("currentAlias")) +} + /* CreateConferenceForm displays the dialog for creating a new conference. * Parameters: * ctxt - The AmContext for the request. diff --git a/database/conference.go b/database/conference.go index ef1065f..15eeafa 100644 --- a/database/conference.go +++ b/database/conference.go @@ -726,6 +726,66 @@ func (c *Conference) GetActivity(ctx context.Context, reportType int) ([]Activit return rc, nil } +// Active user selection types. +const ( + ActiveUserReaders = 0 // select active readers + ActiveUserPosters = 1 // select active posters +) + +/* GetActiveUserEMailAddrs gets the E-mail addresses of each user that's active in the conference, omitting those that have opted out of mass E-mails. + * Parameters: + * ctx - Standard Go context value. + * userSelect - Selects which type of users to return: + * ActiveUserReaders - Select users that have actively read. + * ActiveUserPosters - Select users that have actively posted. + * dayLimit - If less than 0, it is ignored. If equal to 0, this function is a no-op. Otherwise, specifies a limit on the number of days + * between the user's activity and now. + * Returns: + * List of E-mail addresses matchin the criteria, in arbitrary order. + * Standard Go error status. + */ +func (c *Conference) GetActiveUserEMailAddrs(ctx context.Context, userSelect, dayLimit int) ([]string, error) { + if dayLimit == 0 { + return make([]string, 0), nil + } + var myfield string + switch userSelect { + case ActiveUserReaders: + myfield = "s.last_read" + case ActiveUserPosters: + myfield = "s.last_post" + default: + return nil, errors.New("invalid user selection parameter") + } + sql := fmt.Sprintf(`SELECT c.email, %s FROM contacts c, users u, confsettings s, propuser p WHERE c.contactid = u.contactid AND u.uid = s.uid + AND s.confid = ? AND u.is_anon = 0 AND u.uid = p.uid AND p.ndx = %d AND p.data NOT LIKE '%%%s%%' AND ISNULL(%s) = 0 ORDER BY %s DESC`, myfield, UserPropFlags, + util.OptionCharFromIndex(UserFlagMassMailOptOut), myfield, myfield) + rs, err := amdb.QueryContext(ctx, sql, c.ConfId) + if err != nil { + return nil, err + } + var stopPoint *time.Time = nil + if dayLimit > 0 { + mynow := time.Now().UTC() + y, m, d := mynow.AddDate(0, 0, -dayLimit).Date() + stopPointActual := time.Date(y, m, d, 0, 0, 0, 0, mynow.Location()) + stopPoint = &stopPointActual + } + rc := make([]string, 0) + for rs.Next() { + var addy string + var point time.Time + if err = rs.Scan(&addy, &point); err != nil { + return nil, err + } + if stopPoint != nil && point.Before(*stopPoint) { + break + } + rc = append(rc, addy) + } + return rc, nil +} + /* AmGetConference returns a conference given its ID. * Parameters: * ctx - Standard Go context value. diff --git a/database/topic.go b/database/topic.go index 5bf24dc..eb06fd7 100644 --- a/database/topic.go +++ b/database/topic.go @@ -328,6 +328,60 @@ func (t *Topic) GetActivity(ctx context.Context, reportType int) ([]ActivityRepo return rc, nil } +/* GetActiveUserEMailAddrs gets the E-mail addresses of each user that's active in the topic, omitting those that have opted out of mass E-mails. + * Parameters: + * ctx - Standard Go context value. + * userSelect - Selects which type of users to return: + * ActiveUserReaders - Select users that have actively read. + * ActiveUserPosters - Select users that have actively posted. + * dayLimit - If less than 0, it is ignored. If equal to 0, this function is a no-op. Otherwise, specifies a limit on the number of days + * between the user's activity and now. + * Returns: + * List of E-mail addresses matchin the criteria, in arbitrary order. + * Standard Go error status. + */ +func (t *Topic) GetActiveUserEMailAddrs(ctx context.Context, userSelect, dayLimit int) ([]string, error) { + if dayLimit == 0 { + return make([]string, 0), nil + } + var myfield string + switch userSelect { + case ActiveUserReaders: + myfield = "s.last_read" + case ActiveUserPosters: + myfield = "s.last_post" + default: + return nil, errors.New("invalid user selection parameter") + } + sql := fmt.Sprintf(`SELECT c.email, %s FROM contacts c, users u, topicsettings s, propuser p WHERE c.contactid = u.contactid AND u.uid = s.uid + AND s.topicid = ? AND u.is_anon = 0 AND u.uid = p.uid AND p.ndx = %d AND p.data NOT LIKE '%%%s%%' AND ISNULL(%s) = 0 ORDER BY %s DESC`, myfield, UserPropFlags, + util.OptionCharFromIndex(UserFlagMassMailOptOut), myfield, myfield) + rs, err := amdb.QueryContext(ctx, sql, t.TopicId) + if err != nil { + return nil, err + } + var stopPoint *time.Time = nil + if dayLimit > 0 { + mynow := time.Now().UTC() + y, m, d := mynow.AddDate(0, 0, -dayLimit).Date() + stopPointActual := time.Date(y, m, d, 0, 0, 0, 0, mynow.Location()) + stopPoint = &stopPointActual + } + rc := make([]string, 0) + for rs.Next() { + var addy string + var point time.Time + if err = rs.Scan(&addy, &point); err != nil { + return nil, err + } + if stopPoint != nil && point.Before(*stopPoint) { + break + } + rc = append(rc, addy) + } + return rc, nil +} + // backgroundPurgeTopic removes all posts from a topic that's been deleted. func backgroundPurgeTopic(ctx context.Context, topicid int32) error { success := false diff --git a/docs/MISSINGFUNCS.md b/docs/MISSINGFUNCS.md index fc4675c..8b974a2 100644 --- a/docs/MISSINGFUNCS.md +++ b/docs/MISSINGFUNCS.md @@ -69,7 +69,7 @@ _(italicized items can be deferred)_ - ~~Manage members~~ - ~~Custom appearance~~ - ~~Activity reports~~ - - E-mail + - ~~E-mail~~ - Export Messages - Import Messages - Delete Conference diff --git a/email/templates/conf_mass_poster.jet b/email/templates/conf_mass_poster.jet new file mode 100644 index 0000000..c6f4dfa --- /dev/null +++ b/email/templates/conf_mass_poster.jet @@ -0,0 +1,16 @@ +{* + * 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/. + *} +{{ text }} + +You are receiving this message because you are a participant in the +"{{ confName }}" conference in the Amsterdam community "{{ commName }}". +The sender is a host of this conference. To stop receiving mass E-mailed +notices from all Amsterdam community and conference hosts, visit Amsterdam, click +on the "Profile" link, check the box labeled "Don't send me mass E-mail from +community/conference hosts," and click Update to save this preference. diff --git a/email/templates/conf_mass_reader.jet b/email/templates/conf_mass_reader.jet new file mode 100644 index 0000000..4020f5c --- /dev/null +++ b/email/templates/conf_mass_reader.jet @@ -0,0 +1,16 @@ +{* + * 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/. + *} +{{ text }} + +You are receiving this message because you are a reader of the +"{{ confName }}" conference in the Amsterdam community "{{ commName }}". +The sender is a host of this conference. To stop receiving mass E-mailed +notices from all Amsterdam community and conference hosts, visit Amsterdam, click +on the "Profile" link, check the box labeled "Don't send me mass E-mail from +community/conference hosts," and click Update to save this preference. diff --git a/email/templates/topic_mass_poster.jet b/email/templates/topic_mass_poster.jet new file mode 100644 index 0000000..1aba31d --- /dev/null +++ b/email/templates/topic_mass_poster.jet @@ -0,0 +1,17 @@ +{* + * 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/. + *} +{{ text }} + +You are receiving this message because you are a participant in the +"{{ topicName }}" topic in the "{{ confName }}" conference in the +Amsterdam community "{{ commName }}". The sender is a host of the +"{{ confName }}" conference. To stop receiving mass E-mailed notices from +all Amsterdam community and conference hosts, visit Amsterdam, click +on the "Profile" link, check the box labeled "Don't send me mass E-mail from +community/conference hosts," and click Update to save this preference. diff --git a/email/templates/topic_mass_reader.jet b/email/templates/topic_mass_reader.jet new file mode 100644 index 0000000..bc30594 --- /dev/null +++ b/email/templates/topic_mass_reader.jet @@ -0,0 +1,17 @@ +{* + * 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/. + *} +{{ text }} + +You are receiving this message because you are a reader of the +"{{ topicName }}" topic in the "{{ confName }}" conference in the +Amsterdam community "{{ commName }}". The sender is a host of the +"{{ confName }}" conference. To stop receiving mass E-mailed notices from +all Amsterdam community and conference hosts, visit Amsterdam, click +on the "Profile" link, check the box labeled "Don't send me mass E-mail from +community/conference hosts," and click Update to save this preference. diff --git a/main.go b/main.go index c99f7fc..2fa89f4 100644 --- a/main.go +++ b/main.go @@ -123,6 +123,7 @@ func setupEcho() *echo.Echo { confGroup.POST("/custom", ui.AmWrap(ConfCustom)) confGroup.GET("/activity", ui.AmWrap(ConfReports)) confGroup.GET("/email", ui.AmWrap(ConferenceEmailForm)) + confGroup.POST("/email", ui.AmWrap(ConferenceEmail)) confGroup.GET("/hotlist", ui.AmWrap(AddToHotlist)) confGroup.GET("/invite", ui.AmWrap(InviteToConference)) confGroup.GET("/r/:topic", ui.AmWrap(ReadPosts), ui.SetTopic) diff --git a/util/optionset.go b/util/optionset.go index ccab53b..108382c 100644 --- a/util/optionset.go +++ b/util/optionset.go @@ -17,7 +17,7 @@ import ( ) // optionAlphabet is the alphabet from which OptionSets serialize to and from strings. -const optionAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~" +const optionAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$&()*+,-./:;<=>?@[]^_`{|}~" // OptionSet is a bit set that can be persisted as a specially-constructed string. type OptionSet struct { @@ -65,3 +65,11 @@ func OptionSetFromString(s string) *OptionSet { } return &OptionSet{bits: bs} } + +// OptionCharFromIndex converts an integer into the matching character from the option alphabet. +func OptionCharFromIndex(ndx uint) string { + if ndx > uint(len(optionAlphabet)) { + return "" + } + return optionAlphabet[ndx : ndx+1] +}