[feature] Email notifications for new / closed moderation reports (#1628)

* 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
This commit is contained in:
tobi 2023-03-19 13:11:46 +01:00 committed by GitHub
parent 9c55c07be9
commit 7db81cde44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 773 additions and 420 deletions

View file

@ -45,6 +45,18 @@ smtp-password: ""
# Examples: ["mail@example.org"] # Examples: ["mail@example.org"]
# Default: "" # Default: ""
smtp-from: "" 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. 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.
@ -59,11 +71,19 @@ The exception to this requirement is if you're running your mail server (or brid
### When are emails sent? ### 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.
### HTML versus Plaintext ### 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 ## Customization

View file

@ -701,6 +701,18 @@ smtp-password: ""
# Default: "" # Default: ""
smtp-from: "" 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 ##### ##### SYSLOG CONFIG #####
######################### #########################

View file

@ -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"` 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"` 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'"` 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"` 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'"` 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."` 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'"` 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."` 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."` SyslogProtocol string `name:"syslog-protocol" usage:"Protocol to use when directing logs to syslog. Leave empty to connect to local syslog."`

View file

@ -102,11 +102,12 @@ var Defaults = Configuration{
OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
OIDCLinkExisting: false, OIDCLinkExisting: false,
SMTPHost: "", SMTPHost: "",
SMTPPort: 0, SMTPPort: 0,
SMTPUsername: "", SMTPUsername: "",
SMTPPassword: "", SMTPPassword: "",
SMTPFrom: "GoToSocial", SMTPFrom: "GoToSocial",
SMTPDiscloseRecipients: false,
SyslogEnabled: false, SyslogEnabled: false,
SyslogProtocol: "udp", SyslogProtocol: "udp",

View file

@ -132,6 +132,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().String(SMTPUsernameFlag(), cfg.SMTPUsername, fieldtag("SMTPUsername", "usage")) cmd.Flags().String(SMTPUsernameFlag(), cfg.SMTPUsername, fieldtag("SMTPUsername", "usage"))
cmd.Flags().String(SMTPPasswordFlag(), cfg.SMTPPassword, fieldtag("SMTPPassword", "usage")) cmd.Flags().String(SMTPPasswordFlag(), cfg.SMTPPassword, fieldtag("SMTPPassword", "usage"))
cmd.Flags().String(SMTPFromFlag(), cfg.SMTPFrom, fieldtag("SMTPFrom", "usage")) cmd.Flags().String(SMTPFromFlag(), cfg.SMTPFrom, fieldtag("SMTPFrom", "usage"))
cmd.Flags().Bool(SMTPDiscloseRecipientsFlag(), cfg.SMTPDiscloseRecipients, fieldtag("SMTPDiscloseRecipients", "usage"))
// Syslog // Syslog
cmd.Flags().Bool(SyslogEnabledFlag(), cfg.SyslogEnabled, fieldtag("SyslogEnabled", "usage")) cmd.Flags().Bool(SyslogEnabledFlag(), cfg.SyslogEnabled, fieldtag("SyslogEnabled", "usage"))

View file

@ -1924,6 +1924,31 @@ func GetSMTPFrom() string { return global.GetSMTPFrom() }
// SetSMTPFrom safely sets the value for global configuration 'SMTPFrom' field // SetSMTPFrom safely sets the value for global configuration 'SMTPFrom' field
func SetSMTPFrom(v string) { global.SetSMTPFrom(v) } 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 // GetSyslogEnabled safely fetches the Configuration value for state's 'SyslogEnabled' field
func (st *ConfigState) GetSyslogEnabled() (v bool) { func (st *ConfigState) GetSyslogEnabled() (v bool) {
st.mutex.Lock() st.mutex.Lock()

View file

@ -156,3 +156,34 @@ func (i *instanceDB) GetInstanceAccounts(ctx context.Context, domain string, max
return accounts, nil 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
}

View file

@ -24,6 +24,8 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
) )
type InstanceTestSuite struct { type InstanceTestSuite struct {
@ -90,6 +92,42 @@ func (suite *InstanceTestSuite) TestGetInstanceAccounts() {
suite.Len(accounts, 1) 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) { func TestInstanceTestSuite(t *testing.T) {
suite.Run(t, new(InstanceTestSuite)) suite.Run(t, new(InstanceTestSuite))
} }

View file

@ -42,4 +42,8 @@ type Instance interface {
// GetInstancePeers returns a slice of instances that the host instance knows about. // GetInstancePeers returns a slice of instances that the host instance knows about.
GetInstancePeers(ctx context.Context, includeSuspended bool) ([]*gtsmodel.Instance, Error) 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
internal/email/common.go Normal file
View file

@ -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
}

View file

@ -17,15 +17,8 @@
package email package email
import (
"bytes"
"net/smtp"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const ( const (
confirmTemplate = "email_confirm_text.tmpl" confirmTemplate = "email_confirm.tmpl"
confirmSubject = "GoToSocial Email Confirmation" confirmSubject = "GoToSocial Email Confirmation"
) )
@ -43,20 +36,5 @@ type ConfirmData struct {
} }
func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error { func (s *sender) SendConfirmEmail(toAddress string, data ConfirmData) error {
buf := &bytes.Buffer{} return s.sendTemplate(confirmTemplate, confirmSubject, data, toAddress)
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
} }

View file

@ -18,7 +18,10 @@
package email_test package email_test
import ( import (
"testing"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -36,3 +39,152 @@ func (suite *EmailTestSuite) SetupTest() {
suite.sentEmails = make(map[string]string) suite.sentEmails = make(map[string]string)
suite.sender = testrig.NewEmailSender("../../web/template/", suite.sentEmails) suite.sender = testrig.NewEmailSender("../../web/template/", suite.sentEmails)
} }
func (suite *EmailTestSuite) TestTemplateConfirm() {
confirmData := email.ConfirmData{
Username: "test",
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ConfirmLink: "https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa",
}
suite.sender.SendConfirmEmail("user@example.org", confirmData)
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReset() {
resetData := email.ResetData{
Username: "test",
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ResetLink: "https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa",
}
suite.sender.SendResetEmail("user@example.org", resetData)
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Password Reset\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportRemoteToLocal() {
// Someone from a remote instance has reported one of our users.
reportData := email.NewReportData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportURL: "https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R",
ReportDomain: "fossbros-anonymous.io",
ReportTargetDomain: "",
}
if err := suite.sender.SendNewReportEmail([]string{"user@example.org"}, reportData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial New Report\r\n\r\nHello moderator of Test Instance (https://example.org)!\r\n\r\nSomeone from fossbros-anonymous.io has reported a user from your instance.\r\n\r\nTo view the report, paste the following link into your browser: https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportLocalToRemote() {
// Someone from our instance has reported a remote user.
reportData := email.NewReportData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportURL: "https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R",
ReportDomain: "",
ReportTargetDomain: "fossbros-anonymous.io",
}
if err := suite.sender.SendNewReportEmail([]string{"user@example.org"}, reportData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial New Report\r\n\r\nHello moderator of Test Instance (https://example.org)!\r\n\r\nSomeone from your instance has reported a user from fossbros-anonymous.io.\r\n\r\nTo view the report, paste the following link into your browser: https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportLocalToLocal() {
// Someone from our instance has reported another user on our instance.
reportData := email.NewReportData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportURL: "https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R",
ReportDomain: "",
ReportTargetDomain: "",
}
if err := suite.sender.SendNewReportEmail([]string{"user@example.org"}, reportData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial New Report\r\n\r\nHello moderator of Test Instance (https://example.org)!\r\n\r\nSomeone from your instance has reported another user from your instance.\r\n\r\nTo view the report, paste the following link into your browser: https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportMoreThanOneModeratorAddress() {
reportData := email.NewReportData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportURL: "https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R",
ReportDomain: "fossbros-anonymous.io",
ReportTargetDomain: "",
}
// Send the email to multiple addresses
if err := suite.sender.SendNewReportEmail([]string{"user@example.org", "admin@example.org"}, reportData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: Undisclosed Recipients:;\r\nSubject: GoToSocial New Report\r\n\r\nHello moderator of Test Instance (https://example.org)!\r\n\r\nSomeone from fossbros-anonymous.io has reported a user from your instance.\r\n\r\nTo view the report, paste the following link into your browser: https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportMoreThanOneModeratorAddressDisclose() {
config.SetSMTPDiscloseRecipients(true)
reportData := email.NewReportData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportURL: "https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R",
ReportDomain: "fossbros-anonymous.io",
ReportTargetDomain: "",
}
// Send the email to multiple addresses
if err := suite.sender.SendNewReportEmail([]string{"user@example.org", "admin@example.org"}, reportData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org, admin@example.org\r\nSubject: GoToSocial New Report\r\n\r\nHello moderator of Test Instance (https://example.org)!\r\n\r\nSomeone from fossbros-anonymous.io has reported a user from your instance.\r\n\r\nTo view the report, paste the following link into your browser: https://example.org/settings/admin/reports/01GVJHN1RTYZCZTCXVPPPKBX6R\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportClosedOK() {
reportClosedData := email.ReportClosedData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportTargetUsername: "foss_satan",
ReportTargetDomain: "fossbros-anonymous.io",
ActionTakenComment: "User was yeeted. Thank you for reporting!",
}
if err := suite.sender.SendReportClosedEmail("user@example.org", reportClosedData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Report Closed\r\n\r\nHello !\r\n\r\nYou recently reported the account @foss_satan@fossbros-anonymous.io to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report left the following comment: User was yeeted. Thank you for reporting!\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *EmailTestSuite) TestTemplateReportClosedLocalAccountNoComment() {
reportClosedData := email.ReportClosedData{
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ReportTargetUsername: "1happyturtle",
ReportTargetDomain: "",
ActionTakenComment: "",
}
if err := suite.sender.SendReportClosedEmail("user@example.org", reportClosedData); err != nil {
suite.FailNow(err.Error())
}
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Report Closed\r\n\r\nHello !\r\n\r\nYou recently reported the account @1happyturtle to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report did not leave a comment.\r\n\r\n", suite.sentEmails["user@example.org"])
}
func TestEmailTestSuite(t *testing.T) {
suite.Run(t, new(EmailTestSuite))
}

View file

@ -49,62 +49,40 @@ type noopSender struct {
} }
func (s *noopSender) SendConfirmEmail(toAddress string, data ConfirmData) error { func (s *noopSender) SendConfirmEmail(toAddress string, data ConfirmData) error {
buf := &bytes.Buffer{} return s.sendTemplate(confirmTemplate, confirmSubject, data, toAddress)
if err := s.template.ExecuteTemplate(buf, confirmTemplate, data); err != nil {
return err
}
confirmBody := buf.String()
msg, err := assembleMessage(confirmSubject, confirmBody, toAddress, "test@example.org")
if err != nil {
return err
}
log.Tracef(nil, "NOT SENDING confirmation email to %s with contents: %s", toAddress, msg)
if s.sendCallback != nil {
s.sendCallback(toAddress, string(msg))
}
return nil
} }
func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error { func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error {
buf := &bytes.Buffer{} return s.sendTemplate(resetTemplate, resetSubject, data, toAddress)
if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil {
return err
}
resetBody := buf.String()
msg, err := assembleMessage(resetSubject, resetBody, toAddress, "test@example.org")
if err != nil {
return err
}
log.Tracef(nil, "NOT SENDING reset email to %s with contents: %s", toAddress, msg)
if s.sendCallback != nil {
s.sendCallback(toAddress, string(msg))
}
return nil
} }
func (s *noopSender) SendTestEmail(toAddress string, data TestData) error { func (s *noopSender) SendTestEmail(toAddress string, data TestData) error {
return s.sendTemplate(testTemplate, testSubject, data, toAddress)
}
func (s *noopSender) SendNewReportEmail(toAddresses []string, data NewReportData) error {
return s.sendTemplate(newReportTemplate, newReportSubject, data, toAddresses...)
}
func (s *noopSender) SendReportClosedEmail(toAddress string, data ReportClosedData) error {
return s.sendTemplate(reportClosedTemplate, reportClosedSubject, data, toAddress)
}
func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
if err := s.template.ExecuteTemplate(buf, testTemplate, data); err != nil { if err := s.template.ExecuteTemplate(buf, template, data); err != nil {
return err return err
} }
testBody := buf.String()
msg, err := assembleMessage(testSubject, testBody, toAddress, "test@example.org") msg, err := assembleMessage(subject, buf.String(), "test@example.org", toAddresses...)
if err != nil { if err != nil {
return err return err
} }
log.Tracef(nil, "NOT SENDING test email to %s with contents: %s", toAddress, msg) log.Tracef(nil, "NOT SENDING email to %s with contents: %s", toAddresses, msg)
if s.sendCallback != nil { if s.sendCallback != nil {
s.sendCallback(toAddress, string(msg)) s.sendCallback(toAddresses[0], string(msg))
} }
return nil return nil

64
internal/email/report.go Normal file
View file

@ -0,0 +1,64 @@
// 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
const (
newReportTemplate = "email_new_report.tmpl"
newReportSubject = "GoToSocial New Report"
reportClosedTemplate = "email_report_closed.tmpl"
reportClosedSubject = "GoToSocial Report Closed"
)
type NewReportData struct {
// URL of the instance to present to the receiver.
InstanceURL string
// Name of the instance to present to the receiver.
InstanceName string
// URL to open the report in the settings panel.
ReportURL string
// Domain from which the report originated.
// Can be empty string for local reports.
ReportDomain string
// Domain targeted by the report.
// Can be empty string for local reports targeting local users.
ReportTargetDomain string
}
func (s *sender) SendNewReportEmail(toAddresses []string, data NewReportData) error {
return s.sendTemplate(newReportTemplate, newReportSubject, data, toAddresses...)
}
type ReportClosedData struct {
// Username to be addressed.
Username string
// URL of the instance to present to the receiver.
InstanceURL string
// Name of the instance to present to the receiver.
InstanceName string
// Username of the report target.
ReportTargetUsername string
// Domain of the report target.
// Can be empty string for local reports targeting local users.
ReportTargetDomain string
// Comment left by the admin who closed the report.
ActionTakenComment string
}
func (s *sender) SendReportClosedEmail(toAddress string, data ReportClosedData) error {
return s.sendTemplate(reportClosedTemplate, reportClosedSubject, data, toAddress)
}

View file

@ -17,15 +17,8 @@
package email package email
import (
"bytes"
"net/smtp"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const ( const (
resetTemplate = "email_reset_text.tmpl" resetTemplate = "email_reset.tmpl"
resetSubject = "GoToSocial Password Reset" resetSubject = "GoToSocial Password Reset"
) )
@ -43,20 +36,5 @@ type ResetData struct {
} }
func (s *sender) SendResetEmail(toAddress string, data ResetData) error { func (s *sender) SendResetEmail(toAddress string, data ResetData) error {
buf := &bytes.Buffer{} return s.sendTemplate(resetTemplate, resetSubject, data, toAddress)
if err := s.template.ExecuteTemplate(buf, resetTemplate, data); err != nil {
return err
}
resetBody := buf.String()
msg, err := assembleMessage(resetSubject, resetBody, 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
} }

View file

@ -35,6 +35,17 @@ type Sender interface {
// SendTestEmail sends a 'testing email sending' style email to the given toAddress, with the given data. // SendTestEmail sends a 'testing email sending' style email to the given toAddress, with the given data.
SendTestEmail(toAddress string, data TestData) error SendTestEmail(toAddress string, data TestData) error
// SendNewReportEmail sends an email notification to the given addresses, letting them
// know that a new report has been created targeting a user on this instance.
//
// It is expected that the toAddresses have already been filtered to ensure that they
// all belong to admins + moderators.
SendNewReportEmail(toAddresses []string, data NewReportData) error
// SendReportClosedEmail sends an email notification to the given address, letting them
// know that a report that they created has been closed / resolved by an admin.
SendReportClosedEmail(toAddress string, data ReportClosedData) error
} }
// NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong. // NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong.

View file

@ -17,15 +17,8 @@
package email package email
import (
"bytes"
"net/smtp"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const ( const (
testTemplate = "email_test_text.tmpl" testTemplate = "email_test.tmpl"
testSubject = "GoToSocial Test Email" testSubject = "GoToSocial Test Email"
) )
@ -39,20 +32,5 @@ type TestData struct {
} }
func (s *sender) SendTestEmail(toAddress string, data TestData) error { func (s *sender) SendTestEmail(toAddress string, data TestData) error {
buf := &bytes.Buffer{} return s.sendTemplate(testTemplate, testSubject, data, toAddress)
if err := s.template.ExecuteTemplate(buf, testTemplate, data); err != nil {
return err
}
testBody := buf.String()
msg, err := assembleMessage(testSubject, testBody, 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
} }

View file

@ -1,71 +0,0 @@
// 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 (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
)
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_*"))
}
// https://datatracker.ietf.org/doc/html/rfc2822
// I did not read the RFC, I just copy and pasted from
// https://pkg.go.dev/net/smtp#SendMail
// and it did seem to work.
func assembleMessage(mailSubject string, mailBody string, mailTo string, mailFrom string) ([]byte, error) {
if strings.Contains(mailSubject, "\r") || strings.Contains(mailSubject, "\n") {
return nil, errors.New("email subject must not contain newline characters")
}
if strings.Contains(mailFrom, "\r") || strings.Contains(mailFrom, "\n") {
return nil, errors.New("email from address must not contain newline characters")
}
if strings.Contains(mailTo, "\r") || strings.Contains(mailTo, "\n") {
return nil, errors.New("email to address must not contain newline characters")
}
// normalize the message body to use CRLF line endings
mailBody = strings.ReplaceAll(mailBody, "\r\n", "\n")
mailBody = strings.ReplaceAll(mailBody, "\n", "\r\n")
msg := []byte(
"To: " + mailTo + "\r\n" +
"Subject: " + mailSubject + "\r\n" +
"\r\n" +
mailBody + "\r\n",
)
return msg, nil
}

View file

@ -1,59 +0,0 @@
// 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_test
import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/email"
)
type UtilTestSuite struct {
EmailTestSuite
}
func (suite *UtilTestSuite) TestTemplateConfirm() {
confirmData := email.ConfirmData{
Username: "test",
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ConfirmLink: "https://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa",
}
suite.sender.SendConfirmEmail("user@example.org", confirmData)
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org\r\n\r\n", suite.sentEmails["user@example.org"])
}
func (suite *UtilTestSuite) TestTemplateReset() {
resetData := email.ResetData{
Username: "test",
InstanceURL: "https://example.org",
InstanceName: "Test Instance",
ResetLink: "https://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa",
}
suite.sender.SendResetEmail("user@example.org", resetData)
suite.Len(suite.sentEmails, 1)
suite.Equal("To: user@example.org\r\nSubject: GoToSocial Password Reset\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
}
func TestUtilTestSuite(t *testing.T) {
suite.Run(t, &UtilTestSuite{})
}

View file

@ -23,10 +23,12 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
) )
@ -110,7 +112,10 @@ func (p *Processor) ReportGet(ctx context.Context, account *gtsmodel.Account, id
return apimodelReport, nil return apimodelReport, nil
} }
// ReportResolve marks a report with the given id as resolved, and stores the provided actionTakenComment (if not null). // ReportResolve marks a report with the given id as resolved,
// and stores the provided actionTakenComment (if not null).
// If the report creator is from this instance, an email will
// be sent to them to let them know that the report is resolved.
func (p *Processor) ReportResolve(ctx context.Context, account *gtsmodel.Account, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode) { func (p *Processor) ReportResolve(ctx context.Context, account *gtsmodel.Account, id string, actionTakenComment *string) (*apimodel.AdminReport, gtserror.WithCode) {
report, err := p.state.DB.GetReportByID(ctx, id) report, err := p.state.DB.GetReportByID(ctx, id)
if err != nil { if err != nil {
@ -138,6 +143,15 @@ func (p *Processor) ReportResolve(ctx context.Context, account *gtsmodel.Account
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
// Process side effects of closing the report.
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ActivityFlag,
APActivityType: ap.ActivityUpdate,
GTSModel: report,
OriginAccount: account,
TargetAccount: report.Account,
})
apimodelReport, err := p.tc.ReportToAdminAPIReport(ctx, updatedReport, account) apimodelReport, err := p.tc.ReportToAdminAPIReport(ctx, updatedReport, account)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)

View file

@ -81,6 +81,9 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, clientMsg messages
case ap.ObjectProfile, ap.ActorPerson: case ap.ObjectProfile, ap.ActorPerson:
// UPDATE ACCOUNT/PROFILE // UPDATE ACCOUNT/PROFILE
return p.processUpdateAccountFromClientAPI(ctx, clientMsg) return p.processUpdateAccountFromClientAPI(ctx, clientMsg)
case ap.ActivityFlag:
// UPDATE A FLAG/REPORT (mark as resolved/closed)
return p.processUpdateReportFromClientAPI(ctx, clientMsg)
} }
case ap.ActivityAccept: case ap.ActivityAccept:
// ACCEPT // ACCEPT
@ -240,6 +243,21 @@ func (p *Processor) processUpdateAccountFromClientAPI(ctx context.Context, clien
return p.federateAccountUpdate(ctx, account, clientMsg.OriginAccount) return p.federateAccountUpdate(ctx, account, clientMsg.OriginAccount)
} }
func (p *Processor) processUpdateReportFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error {
report, ok := clientMsg.GTSModel.(*gtsmodel.Report)
if !ok {
return errors.New("report was not parseable as *gtsmodel.Report")
}
if report.Account.IsRemote() {
// Report creator is a remote account,
// we shouldn't email or notify them.
return nil
}
return p.notifyReportClosed(ctx, report)
}
func (p *Processor) processAcceptFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error { func (p *Processor) processAcceptFollowFromClientAPI(ctx context.Context, clientMsg messages.FromClientAPI) error {
follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow)
if !ok { if !ok {
@ -349,14 +367,17 @@ func (p *Processor) processReportAccountFromClientAPI(ctx context.Context, clien
return errors.New("report was not parseable as *gtsmodel.Report") return errors.New("report was not parseable as *gtsmodel.Report")
} }
// TODO: in a separate PR, also email admin(s) if *report.Forwarded {
if err := p.federateReport(ctx, report); err != nil {
if !*report.Forwarded { return fmt.Errorf("processReportAccountFromClientAPI: error federating report: %w", err)
// nothing to do, don't federate the report }
return nil
} }
return p.federateReport(ctx, report) if err := p.notifyReport(ctx, report); err != nil {
return fmt.Errorf("processReportAccountFromClientAPI: error notifying report: %w", err)
}
return nil
} }
// TODO: move all the below functions into federation.Federator // TODO: move all the below functions into federation.Federator

View file

@ -19,11 +19,14 @@ package processing
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/stream"
@ -308,6 +311,96 @@ func (p *Processor) notifyAnnounce(ctx context.Context, status *gtsmodel.Status)
return nil return nil
} }
func (p *Processor) notifyReport(ctx context.Context, report *gtsmodel.Report) error {
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
if err != nil {
return fmt.Errorf("notifyReport: error getting instance: %w", err)
}
toAddresses, err := p.state.DB.GetInstanceModeratorAddresses(ctx)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
// No registered moderator addresses.
return nil
}
return fmt.Errorf("notifyReport: error getting instance moderator addresses: %w", err)
}
if report.Account == nil {
report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
if err != nil {
return fmt.Errorf("notifyReport: error getting report account: %w", err)
}
}
if report.TargetAccount == nil {
report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
if err != nil {
return fmt.Errorf("notifyReport: error getting report target account: %w", err)
}
}
reportData := email.NewReportData{
InstanceURL: instance.URI,
InstanceName: instance.Title,
ReportURL: instance.URI + "/settings/admin/reports/" + report.ID,
ReportDomain: report.Account.Domain,
ReportTargetDomain: report.TargetAccount.Domain,
}
if err := p.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil {
return fmt.Errorf("notifyReport: error emailing instance moderators: %w", err)
}
return nil
}
func (p *Processor) notifyReportClosed(ctx context.Context, report *gtsmodel.Report) error {
user, err := p.state.DB.GetUserByAccountID(ctx, report.Account.ID)
if err != nil {
return fmt.Errorf("notifyReportClosed: db error getting user: %w", err)
}
if user.ConfirmedAt.IsZero() || !*user.Approved || *user.Disabled || user.Email == "" {
// Only email users who:
// - are confirmed
// - are approved
// - are not disabled
// - have an email address
return nil
}
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
if err != nil {
return fmt.Errorf("notifyReportClosed: db error getting instance: %w", err)
}
if report.Account == nil {
report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
if err != nil {
return fmt.Errorf("notifyReportClosed: error getting report account: %w", err)
}
}
if report.TargetAccount == nil {
report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
if err != nil {
return fmt.Errorf("notifyReportClosed: error getting report target account: %w", err)
}
}
reportClosedData := email.ReportClosedData{
Username: report.Account.Username,
InstanceURL: instance.URI,
InstanceName: instance.Title,
ReportTargetUsername: report.TargetAccount.Username,
ReportTargetDomain: report.TargetAccount.Domain,
ActionTakenComment: report.ActionTaken,
}
return p.emailSender.SendReportClosedEmail(user.Email, reportClosedData)
}
// timelineStatus processes the given new status and inserts it into // timelineStatus processes the given new status and inserts it into
// the HOME timelines of accounts that follow the status author. // the HOME timelines of accounts that follow the status author.
func (p *Processor) timelineStatus(ctx context.Context, status *gtsmodel.Status) error { func (p *Processor) timelineStatus(ctx context.Context, status *gtsmodel.Status) error {

View file

@ -359,10 +359,15 @@ func (p *Processor) processCreateBlockFromFederator(ctx context.Context, federat
} }
func (p *Processor) processCreateFlagFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { func (p *Processor) processCreateFlagFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error {
// TODO: handle side effects of flag creation: incomingReport, ok := federatorMsg.GTSModel.(*gtsmodel.Report)
// - send email to admins if !ok {
// - notify admins return errors.New("flag was not parseable as *gtsmodel.Report")
return nil }
// TODO: handle additional side effects of flag creation:
// - notify admins by dm / notification
return p.notifyReport(ctx, incomingReport)
} }
// processUpdateAccountFromFederator handles Activity Update and Object Profile // processUpdateAccountFromFederator handles Activity Update and Object Profile

View file

@ -48,6 +48,7 @@ type Processor struct {
statusTimelines timeline.Manager statusTimelines timeline.Manager
state *state.State state *state.State
filter visibility.Filter filter visibility.Filter
emailSender email.Sender
/* /*
SUB-PROCESSORS SUB-PROCESSORS
@ -119,8 +120,9 @@ func NewProcessor(
StatusPrepareFunction(state.DB, tc), StatusPrepareFunction(state.DB, tc),
StatusSkipInsertFunction(), StatusSkipInsertFunction(),
), ),
state: state, state: state,
filter: filter, filter: filter,
emailSender: emailSender,
} }
// sub processors // sub processors

View file

@ -113,6 +113,7 @@ EXPECT=$(cat <<"EOF"
"port": 6969, "port": 6969,
"protocol": "http", "protocol": "http",
"request-id-header": "X-Trace-Id", "request-id-header": "X-Trace-Id",
"smtp-disclose-recipients": true,
"smtp-from": "queen.rip.in.piss@terfisland.org", "smtp-from": "queen.rip.in.piss@terfisland.org",
"smtp-host": "example.com", "smtp-host": "example.com",
"smtp-password": "hunter2", "smtp-password": "hunter2",
@ -222,6 +223,7 @@ GTS_SMTP_PORT=4269 \
GTS_SMTP_USERNAME='sex-haver' \ GTS_SMTP_USERNAME='sex-haver' \
GTS_SMTP_PASSWORD='hunter2' \ GTS_SMTP_PASSWORD='hunter2' \
GTS_SMTP_FROM='queen.rip.in.piss@terfisland.org' \ GTS_SMTP_FROM='queen.rip.in.piss@terfisland.org' \
GTS_SMTP_DISCLOSE_RECIPIENTS=true \
GTS_SYSLOG_ENABLED=true \ GTS_SYSLOG_ENABLED=true \
GTS_SYSLOG_PROTOCOL='udp' \ GTS_SYSLOG_PROTOCOL='udp' \
GTS_SYSLOG_ADDRESS='127.0.0.1:6969' \ GTS_SYSLOG_ADDRESS='127.0.0.1:6969' \

View file

@ -106,11 +106,12 @@ var testDefaults = config.Configuration{
OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, OIDCScopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
OIDCLinkExisting: false, OIDCLinkExisting: false,
SMTPHost: "", SMTPHost: "",
SMTPPort: 0, SMTPPort: 0,
SMTPUsername: "", SMTPUsername: "",
SMTPPassword: "", SMTPPassword: "",
SMTPFrom: "GoToSocial", SMTPFrom: "GoToSocial",
SMTPDiscloseRecipients: false,
SyslogEnabled: false, SyslogEnabled: false,
SyslogProtocol: "udp", SyslogProtocol: "udp",

View file

@ -0,0 +1,28 @@
{{- /*
// 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/>.
*/ -}}
Hello {{.Username}}!
You are receiving this mail because you've requested an account on {{.InstanceURL}}.
We just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:
{{.ConfirmLink}}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}

View file

@ -1,47 +0,0 @@
{{- /*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/ -}}
<!DOCTYPE html>
<html>
</head>
<body>
<div>
<h1>
Hello {{.Username}}!
</h1>
</div>
<div>
<p>
You are receiving this mail because you've requested an account on <a href="{{.InstanceURL}}">{{.InstanceName}}</a>.
</p>
<p>
We just need to confirm that this is your email address. To confirm your email, <a href="{{.ConfirmLink}}">click here</a> or paste the following in your browser's address bar:
</p>
<p>
<code>
{{.ConfirmLink}}
</code>
</p>
</div>
<div>
<p>
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href="{{.InstanceURL}}">{{.InstanceName}}</a>.
</p>
</div>
</body>
</html>

View file

@ -1,27 +0,0 @@
{{- /*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/ -}}
Hello {{.Username}}!
You are receiving this mail because you've requested an account on {{.InstanceURL}}.
We just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:
{{.ConfirmLink}}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}

View file

@ -0,0 +1,26 @@
{{- /*
// 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/>.
*/ -}}
Hello moderator of {{ .InstanceName }} ({{ .InstanceURL }})!
{{ if .ReportDomain }}Someone from {{ .ReportDomain }} has reported a user from your instance.
{{- else if .ReportTargetDomain }}Someone from your instance has reported a user from {{ .ReportTargetDomain }}.
{{- else }}Someone from your instance has reported another user from your instance.{{ end }}
To view the report, paste the following link into your browser: {{ .ReportURL }}

View file

@ -0,0 +1,27 @@
{{- /*
// 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/>.
*/ -}}
Hello {{.Username}}!
You recently reported the account @{{ .ReportTargetUsername }}{{ if .ReportTargetDomain }}@{{ .ReportTargetDomain }}{{ end }} to the moderator(s) of {{ .InstanceName }} ({{ .InstanceURL }}).
The report you submitted has now been closed.
{{ if .ActionTakenComment }}The moderator who closed the report left the following comment: {{ .ActionTakenComment }}
{{- else }}The moderator who closed the report did not leave a comment.{{ end }}

View file

@ -0,0 +1,28 @@
{{- /*
// 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/>.
*/ -}}
Hello {{.Username}}!
You are receiving this mail because a password reset has been requested for your account on {{.InstanceURL}}.
To reset your password, paste the following in your browser's address bar:
{{.ResetLink}}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}.

View file

@ -1,47 +0,0 @@
{{- /*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/ -}}
<!DOCTYPE html>
<html>
</head>
<body>
<div>
<h1>
Hello {{.Username}}!
</h1>
</div>
<div>
<p>
You are receiving this mail because a password reset has been requested for your account on <a href="{{.InstanceURL}}">{{.InstanceName}}</a>.
</p>
<p>
To reset your password, <a href="{{.ResetLink}}">click here</a> or paste the following in your browser's address bar:
</p>
<p>
<code>
{{.ResetLink}}
</code>
</p>
</div>
<div>
<p>
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href="{{.InstanceURL}}">{{.InstanceName}}</a>.
</p>
</div>
</body>
</html>

View file

@ -1,27 +0,0 @@
{{- /*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/ -}}
Hello {{.Username}}!
You are receiving this mail because a password reset has been requested for your account on {{.InstanceURL}}.
To reset your password, paste the following in your browser's address bar:
{{.ResetLink}}
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}.