Skip to content

Commit

Permalink
[feature] Email notifications for new / closed moderation reports (#1628
Browse files Browse the repository at this point in the history
)

* start fiddling about with email sending to allow multiple recipients

* do some fiddling

* notifs working

* notify on closed report

* finishing up

* envparsing

* use strings.ContainsAny
  • Loading branch information
tsmethurst committed Mar 19, 2023
1 parent 9c55c07 commit 7db81cd
Show file tree
Hide file tree
Showing 35 changed files with 770 additions and 417 deletions.
24 changes: 22 additions & 2 deletions docs/configuration/smtp.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ smtp-password: ""
# Examples: ["mail@example.org"]
# Default: ""
smtp-from: ""

# Bool. If true, when an email is sent that has multiple recipients, each recipient
# will be included in the To field, so that each recipient can see who else got the
# email, and they can 'reply all' to the other recipients if they want to.
#
# If false, email will be sent to Undisclosed Recipients, and each recipient will not
# be able to see who else received the email.
#
# It might be useful to change this setting to 'true' if you want to be able to discuss
# new moderation reports with other admins by 'replying-all' to the notification email.
# Default: false
smtp-disclose-recipients: false
```

Note that if you don't set `Host`, then email sending via smtp will be disabled, and the other settings will be ignored. GoToSocial will still log (at trace level) emails that *would* have been sent if smtp was enabled.
Expand All @@ -59,11 +71,19 @@ The exception to this requirement is if you're running your mail server (or brid

### When are emails sent?

Currently, emails are only sent to users to request email confirmation when a new account is created, or to serve password reset requests. More email functionality will probably be added later.
Currently, emails are sent:

- To the provided email address of a new user to request email confirmation when a new account is created via the API.
- To all active instance moderators + admins when a new moderation report is received. By default, recipients are Bcc'd, but you can change this behavior with the setting `smtp-disclose-recipients`.
- To the creator of a report (on this instance) when the report is closed by a moderator.

### Can I test if my SMTP configuration is correct?

Yes, you can use the API to send a test email to yourself. Check the API documentation for the `/api/v1/admin/email/test` endpoint.

This comment has been minimized.

Copy link
@mirabilos

mirabilos Jun 9, 2024

Contributor

this line probably should be updated for 0.16


### HTML versus Plaintext

Emails are sent in HTML by default. At this point, there is no option to send emails in plaintext, but this is something that might be added later if there's enough demand for it.
Emails are sent in plaintext by default. At this point, there is no option to send emails in html, but this is something that might be added later if there's enough demand for it.

## Customization

Expand Down
12 changes: 12 additions & 0 deletions example/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,18 @@ smtp-password: ""
# Default: ""
smtp-from: ""

# Bool. If true, when an email is sent that has multiple recipients, each recipient
# will be included in the To field, so that each recipient can see who else got the
# email, and they can 'reply all' to the other recipients if they want to.
#
# If false, email will be sent to Undisclosed Recipients, and each recipient will not
# be able to see who else received the email.
#
# It might be useful to change this setting to 'true' if you want to be able to discuss
# new moderation reports with other admins by 'replying-all' to the notification email.
# Default: false
smtp-disclose-recipients: false

#########################
##### SYSLOG CONFIG #####
#########################
Expand Down
11 changes: 6 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@ type Configuration struct {
OIDCLinkExisting bool `name:"oidc-link-existing" usage:"link existing user accounts to OIDC logins based on the stored email value"`
OIDCAdminGroups []string `name:"oidc-admin-groups" usage:"Membership of one of the listed groups makes someone a GtS admin"`

SMTPHost string `name:"smtp-host" usage:"Host of the smtp server. Eg., 'smtp.eu.mailgun.org'"`
SMTPPort int `name:"smtp-port" usage:"Port of the smtp server. Eg., 587"`
SMTPUsername string `name:"smtp-username" usage:"Username to authenticate with the smtp server as. Eg., 'postmaster@mail.example.org'"`
SMTPPassword string `name:"smtp-password" usage:"Password to pass to the smtp server."`
SMTPFrom string `name:"smtp-from" usage:"Address to use as the 'from' field of the email. Eg., 'gotosocial@example.org'"`
SMTPHost string `name:"smtp-host" usage:"Host of the smtp server. Eg., 'smtp.eu.mailgun.org'"`
SMTPPort int `name:"smtp-port" usage:"Port of the smtp server. Eg., 587"`
SMTPUsername string `name:"smtp-username" usage:"Username to authenticate with the smtp server as. Eg., 'postmaster@mail.example.org'"`
SMTPPassword string `name:"smtp-password" usage:"Password to pass to the smtp server."`
SMTPFrom string `name:"smtp-from" usage:"Address to use as the 'from' field of the email. Eg., 'gotosocial@example.org'"`
SMTPDiscloseRecipients bool `name:"smtp-disclose-recipients" usage:"If true, email notifications sent to multiple recipients will be To'd to every recipient at once. If false, recipients will not be disclosed"`

SyslogEnabled bool `name:"syslog-enabled" usage:"Enable the syslog logging hook. Logs will be mirrored to the configured destination."`
SyslogProtocol string `name:"syslog-protocol" usage:"Protocol to use when directing logs to syslog. Leave empty to connect to local syslog."`
Expand Down
11 changes: 6 additions & 5 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ var Defaults = Configuration{
OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
OIDCLinkExisting: false,

SMTPHost: "",
SMTPPort: 0,
SMTPUsername: "",
SMTPPassword: "",
SMTPFrom: "GoToSocial",
SMTPHost: "",
SMTPPort: 0,
SMTPUsername: "",
SMTPPassword: "",
SMTPFrom: "GoToSocial",
SMTPDiscloseRecipients: false,

SyslogEnabled: false,
SyslogProtocol: "udp",
Expand Down
1 change: 1 addition & 0 deletions internal/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().String(SMTPUsernameFlag(), cfg.SMTPUsername, fieldtag("SMTPUsername", "usage"))
cmd.Flags().String(SMTPPasswordFlag(), cfg.SMTPPassword, fieldtag("SMTPPassword", "usage"))
cmd.Flags().String(SMTPFromFlag(), cfg.SMTPFrom, fieldtag("SMTPFrom", "usage"))
cmd.Flags().Bool(SMTPDiscloseRecipientsFlag(), cfg.SMTPDiscloseRecipients, fieldtag("SMTPDiscloseRecipients", "usage"))

// Syslog
cmd.Flags().Bool(SyslogEnabledFlag(), cfg.SyslogEnabled, fieldtag("SyslogEnabled", "usage"))
Expand Down
25 changes: 25 additions & 0 deletions internal/config/helpers.gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,31 @@ func GetSMTPFrom() string { return global.GetSMTPFrom() }
// SetSMTPFrom safely sets the value for global configuration 'SMTPFrom' field
func SetSMTPFrom(v string) { global.SetSMTPFrom(v) }

// GetSMTPDiscloseRecipients safely fetches the Configuration value for state's 'SMTPDiscloseRecipients' field
func (st *ConfigState) GetSMTPDiscloseRecipients() (v bool) {
st.mutex.Lock()
v = st.config.SMTPDiscloseRecipients
st.mutex.Unlock()
return
}

// SetSMTPDiscloseRecipients safely sets the Configuration value for state's 'SMTPDiscloseRecipients' field
func (st *ConfigState) SetSMTPDiscloseRecipients(v bool) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.SMTPDiscloseRecipients = v
st.reloadToViper()
}

// SMTPDiscloseRecipientsFlag returns the flag name for the 'SMTPDiscloseRecipients' field
func SMTPDiscloseRecipientsFlag() string { return "smtp-disclose-recipients" }

// GetSMTPDiscloseRecipients safely fetches the value for global configuration 'SMTPDiscloseRecipients' field
func GetSMTPDiscloseRecipients() bool { return global.GetSMTPDiscloseRecipients() }

// SetSMTPDiscloseRecipients safely sets the value for global configuration 'SMTPDiscloseRecipients' field
func SetSMTPDiscloseRecipients(v bool) { global.SetSMTPDiscloseRecipients(v) }

// GetSyslogEnabled safely fetches the Configuration value for state's 'SyslogEnabled' field
func (st *ConfigState) GetSyslogEnabled() (v bool) {
st.mutex.Lock()
Expand Down
31 changes: 31 additions & 0 deletions internal/db/bundb/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,34 @@ func (i *instanceDB) GetInstanceAccounts(ctx context.Context, domain string, max

return accounts, nil
}

func (i *instanceDB) GetInstanceModeratorAddresses(ctx context.Context) ([]string, db.Error) {
addresses := []string{}

// Select email addresses of approved, confirmed,
// and enabled moderators or admins.

q := i.conn.
NewSelect().
TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
Column("user.email").
Where("? = ?", bun.Ident("user.approved"), true).
Where("? IS NOT NULL", bun.Ident("user.confirmed_at")).
Where("? = ?", bun.Ident("user.disabled"), false).
WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.
Where("? = ?", bun.Ident("user.moderator"), true).
WhereOr("? = ?", bun.Ident("user.admin"), true)
}).
OrderExpr("? ASC", bun.Ident("user.email"))

if err := q.Scan(ctx, &addresses); err != nil {
return nil, i.conn.ProcessError(err)
}

if len(addresses) == 0 {
return nil, db.ErrNoEntries
}

return addresses, nil
}
38 changes: 38 additions & 0 deletions internal/db/bundb/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)

type InstanceTestSuite struct {
Expand Down Expand Up @@ -90,6 +92,42 @@ func (suite *InstanceTestSuite) TestGetInstanceAccounts() {
suite.Len(accounts, 1)
}

func (suite *InstanceTestSuite) TestGetInstanceModeratorAddressesOK() {
// We have one admin user by default.
addresses, err := suite.db.GetInstanceModeratorAddresses(context.Background())
suite.NoError(err)
suite.EqualValues([]string{"admin@example.org"}, addresses)
}

func (suite *InstanceTestSuite) TestGetInstanceModeratorAddressesZorkAsModerator() {
// Promote zork to moderator role.
testUser := &gtsmodel.User{}
*testUser = *suite.testUsers["local_account_1"]
testUser.Moderator = testrig.TrueBool()
if err := suite.db.UpdateUser(context.Background(), testUser, "moderator"); err != nil {
suite.FailNow(err.Error())
}

addresses, err := suite.db.GetInstanceModeratorAddresses(context.Background())
suite.NoError(err)
suite.EqualValues([]string{"admin@example.org", "zork@example.org"}, addresses)
}

func (suite *InstanceTestSuite) TestGetInstanceModeratorAddressesNoAdmin() {
// Demote admin from admin + moderator roles.
testUser := &gtsmodel.User{}
*testUser = *suite.testUsers["admin_account"]
testUser.Admin = testrig.FalseBool()
testUser.Moderator = testrig.FalseBool()
if err := suite.db.UpdateUser(context.Background(), testUser, "admin", "moderator"); err != nil {
suite.FailNow(err.Error())
}

addresses, err := suite.db.GetInstanceModeratorAddresses(context.Background())
suite.ErrorIs(err, db.ErrNoEntries)
suite.Empty(addresses)
}

func TestInstanceTestSuite(t *testing.T) {
suite.Run(t, new(InstanceTestSuite))
}
4 changes: 4 additions & 0 deletions internal/db/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ type Instance interface {

// GetInstancePeers returns a slice of instances that the host instance knows about.
GetInstancePeers(ctx context.Context, includeSuspended bool) ([]*gtsmodel.Instance, Error)

// GetInstanceModeratorAddresses returns a slice of email addresses belonging to active
// (as in, not suspended) moderators + admins on this instance.
GetInstanceModeratorAddresses(ctx context.Context) ([]string, Error)
}
112 changes: 112 additions & 0 deletions internal/email/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package email

import (
"bytes"
"errors"
"fmt"
"net/smtp"
"os"
"path/filepath"
"strings"
"text/template"

"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)

func (s *sender) sendTemplate(template string, subject string, data any, toAddresses ...string) error {
buf := &bytes.Buffer{}
if err := s.template.ExecuteTemplate(buf, template, data); err != nil {
return err
}

msg, err := assembleMessage(subject, buf.String(), s.from, toAddresses...)
if err != nil {
return err
}

if err := smtp.SendMail(s.hostAddress, s.auth, s.from, toAddresses, msg); err != nil {
return gtserror.SetType(err, gtserror.TypeSMTP)
}

return nil
}

func loadTemplates(templateBaseDir string) (*template.Template, error) {
if !filepath.IsAbs(templateBaseDir) {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("error getting current working directory: %s", err)
}
templateBaseDir = filepath.Join(cwd, templateBaseDir)
}

// look for all templates that start with 'email_'
return template.ParseGlob(filepath.Join(templateBaseDir, "email_*"))
}

// assembleMessage assembles a valid email message following:
// - https://datatracker.ietf.org/doc/html/rfc2822
// - https://pkg.go.dev/net/smtp#SendMail
func assembleMessage(mailSubject string, mailBody string, mailFrom string, mailTo ...string) ([]byte, error) {
if strings.ContainsAny(mailSubject, "\r\n") {
return nil, errors.New("email subject must not contain newline characters")
}

if strings.ContainsAny(mailFrom, "\r\n") {
return nil, errors.New("email from address must not contain newline characters")
}

for _, to := range mailTo {
if strings.ContainsAny(to, "\r\n") {
return nil, errors.New("email to address must not contain newline characters")
}
}

// Normalize the message body to use CRLF line endings
const CRLF = "\r\n"
mailBody = strings.ReplaceAll(mailBody, CRLF, "\n")
mailBody = strings.ReplaceAll(mailBody, "\n", CRLF)

msg := bytes.Buffer{}
switch {
case len(mailTo) == 1:
// Address email directly to the one recipient.
msg.WriteString("To: " + mailTo[0] + CRLF)
case config.GetSMTPDiscloseRecipients():
// Simply address To all recipients.
msg.WriteString("To: " + strings.Join(mailTo, ", ") + CRLF)
default:
// Address To anonymous group.
//
// Email will be sent to all recipients but we shouldn't include Bcc header.
//
// From the smtp.SendMail function: 'Sending "Bcc" messages is accomplished by
// including an email address in the to parameter but not including it in the
// msg headers.'
msg.WriteString("To: Undisclosed Recipients:;" + CRLF)
}
msg.WriteString("Subject: " + mailSubject + CRLF)
msg.WriteString(CRLF)
msg.WriteString(mailBody)
msg.WriteString(CRLF)

return msg.Bytes(), nil
}
26 changes: 2 additions & 24 deletions internal/email/confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@

package email

import (
"bytes"
"net/smtp"

"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)

const (
confirmTemplate = "email_confirm_text.tmpl"
confirmTemplate = "email_confirm.tmpl"
confirmSubject = "GoToSocial Email Confirmation"
)

Expand All @@ -43,20 +36,5 @@ type ConfirmData struct {
}

func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error {
buf := &bytes.Buffer{}
if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil {
return err
}
confirmBody := buf.String()

msg, err := assembleMessage(confirmSubject, confirmBody, toAddress, s.from)
if err != nil {
return err
}

if err := smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg); err != nil {
return gtserror.SetType(err, gtserror.TypeSMTP)
}

return nil
return s.sendTemplate(confirmTemplate, confirmSubject, data, toAddress)
}
Loading

0 comments on commit 7db81cd

Please sign in to comment.