diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 11 | ||||
| -rw-r--r-- | internal/config/defaults.go | 11 | ||||
| -rw-r--r-- | internal/config/flags.go | 1 | ||||
| -rw-r--r-- | internal/config/helpers.gen.go | 25 | ||||
| -rw-r--r-- | internal/db/bundb/instance.go | 31 | ||||
| -rw-r--r-- | internal/db/bundb/instance_test.go | 38 | ||||
| -rw-r--r-- | internal/db/instance.go | 4 | ||||
| -rw-r--r-- | internal/email/common.go | 112 | ||||
| -rw-r--r-- | internal/email/confirm.go | 26 | ||||
| -rw-r--r-- | internal/email/email_test.go | 152 | ||||
| -rw-r--r-- | internal/email/noopsender.go | 54 | ||||
| -rw-r--r-- | internal/email/report.go | 64 | ||||
| -rw-r--r-- | internal/email/reset.go | 26 | ||||
| -rw-r--r-- | internal/email/sender.go | 11 | ||||
| -rw-r--r-- | internal/email/test.go | 26 | ||||
| -rw-r--r-- | internal/email/util.go | 71 | ||||
| -rw-r--r-- | internal/email/util_test.go | 59 | ||||
| -rw-r--r-- | internal/processing/admin/report.go | 16 | ||||
| -rw-r--r-- | internal/processing/fromclientapi.go | 31 | ||||
| -rw-r--r-- | internal/processing/fromcommon.go | 93 | ||||
| -rw-r--r-- | internal/processing/fromfederator.go | 13 | ||||
| -rw-r--r-- | internal/processing/processor.go | 6 | 
22 files changed, 619 insertions, 262 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 21c1ca470..a1e00ea8d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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."` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b687b43f1..17cc71086 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -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", diff --git a/internal/config/flags.go b/internal/config/flags.go index 09e927785..e9925ded0 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -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")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 53553d851..6fc195ad0 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -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() diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go index b4bdeb1d9..4fa898639 100644 --- a/internal/db/bundb/instance.go +++ b/internal/db/bundb/instance.go @@ -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 +} diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go index 4269df5ca..580a7699b 100644 --- a/internal/db/bundb/instance_test.go +++ b/internal/db/bundb/instance_test.go @@ -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 { @@ -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 := >smodel.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 := >smodel.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))  } diff --git a/internal/db/instance.go b/internal/db/instance.go index dff471193..3166a0a18 100644 --- a/internal/db/instance.go +++ b/internal/db/instance.go @@ -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)  } diff --git a/internal/email/common.go b/internal/email/common.go new file mode 100644 index 000000000..ab4176895 --- /dev/null +++ b/internal/email/common.go @@ -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 +} diff --git a/internal/email/confirm.go b/internal/email/confirm.go index a6548e7d1..9f05a4f71 100644 --- a/internal/email/confirm.go +++ b/internal/email/confirm.go @@ -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"  ) @@ -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)  } diff --git a/internal/email/email_test.go b/internal/email/email_test.go index 2fa52ec4a..91d128ef8 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -18,7 +18,10 @@  package email_test  import ( +	"testing" +  	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/email"  	"github.com/superseriousbusiness/gotosocial/testrig"  ) @@ -36,3 +39,152 @@ func (suite *EmailTestSuite) SetupTest() {  	suite.sentEmails = make(map[string]string)  	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)) +} diff --git a/internal/email/noopsender.go b/internal/email/noopsender.go index 7164440f3..0ed7ff747 100644 --- a/internal/email/noopsender.go +++ b/internal/email/noopsender.go @@ -49,62 +49,40 @@ type noopSender struct {  }  func (s *noopSender) 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, "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 +	return s.sendTemplate(confirmTemplate, confirmSubject, data, toAddress)  }  func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error { -	buf := &bytes.Buffer{} -	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 -	} +	return s.sendTemplate(resetTemplate, resetSubject, data, toAddress) +} -	log.Tracef(nil, "NOT SENDING reset email to %s with contents: %s", toAddress, msg) +func (s *noopSender) SendTestEmail(toAddress string, data TestData) error { +	return s.sendTemplate(testTemplate, testSubject, data, toAddress) +} -	if s.sendCallback != nil { -		s.sendCallback(toAddress, string(msg)) -	} +func (s *noopSender) SendNewReportEmail(toAddresses []string, data NewReportData) error { +	return s.sendTemplate(newReportTemplate, newReportSubject, data, toAddresses...) +} -	return nil +func (s *noopSender) SendReportClosedEmail(toAddress string, data ReportClosedData) error { +	return s.sendTemplate(reportClosedTemplate, reportClosedSubject, data, toAddress)  } -func (s *noopSender) SendTestEmail(toAddress string, data TestData) error { +func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error {  	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  	} -	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 {  		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 { -		s.sendCallback(toAddress, string(msg)) +		s.sendCallback(toAddresses[0], string(msg))  	}  	return nil diff --git a/internal/email/report.go b/internal/email/report.go new file mode 100644 index 000000000..7c4c10c69 --- /dev/null +++ b/internal/email/report.go @@ -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) +} diff --git a/internal/email/reset.go b/internal/email/reset.go index cb1da9fee..eb931a312 100644 --- a/internal/email/reset.go +++ b/internal/email/reset.go @@ -17,15 +17,8 @@  package email -import ( -	"bytes" -	"net/smtp" - -	"github.com/superseriousbusiness/gotosocial/internal/gtserror" -) -  const ( -	resetTemplate = "email_reset_text.tmpl" +	resetTemplate = "email_reset.tmpl"  	resetSubject  = "GoToSocial Password Reset"  ) @@ -43,20 +36,5 @@ type ResetData struct {  }  func (s *sender) SendResetEmail(toAddress string, data ResetData) error { -	buf := &bytes.Buffer{} -	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 +	return s.sendTemplate(resetTemplate, resetSubject, data, toAddress)  } diff --git a/internal/email/sender.go b/internal/email/sender.go index 13dd26531..b0d883d9d 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -35,6 +35,17 @@ type Sender interface {  	// SendTestEmail sends a 'testing email sending' style email to the given toAddress, with the given data.  	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. diff --git a/internal/email/test.go b/internal/email/test.go index 1e411f161..7d6ac2b3b 100644 --- a/internal/email/test.go +++ b/internal/email/test.go @@ -17,15 +17,8 @@  package email -import ( -	"bytes" -	"net/smtp" - -	"github.com/superseriousbusiness/gotosocial/internal/gtserror" -) -  const ( -	testTemplate = "email_test_text.tmpl" +	testTemplate = "email_test.tmpl"  	testSubject  = "GoToSocial Test Email"  ) @@ -39,20 +32,5 @@ type TestData struct {  }  func (s *sender) SendTestEmail(toAddress string, data TestData) error { -	buf := &bytes.Buffer{} -	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 +	return s.sendTemplate(testTemplate, testSubject, data, toAddress)  } diff --git a/internal/email/util.go b/internal/email/util.go deleted file mode 100644 index bd024a3e0..000000000 --- a/internal/email/util.go +++ /dev/null @@ -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 -} diff --git a/internal/email/util_test.go b/internal/email/util_test.go deleted file mode 100644 index 281d5630b..000000000 --- a/internal/email/util_test.go +++ /dev/null @@ -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{}) -} diff --git a/internal/processing/admin/report.go b/internal/processing/admin/report.go index 3a5435d9e..174bd3c24 100644 --- a/internal/processing/admin/report.go +++ b/internal/processing/admin/report.go @@ -23,10 +23,12 @@ import (  	"strconv"  	"time" +	"github.com/superseriousbusiness/gotosocial/internal/ap"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/messages"  	"github.com/superseriousbusiness/gotosocial/internal/util"  ) @@ -110,7 +112,10 @@ func (p *Processor) ReportGet(ctx context.Context, account *gtsmodel.Account, id  	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) {  	report, err := p.state.DB.GetReportByID(ctx, id)  	if err != nil { @@ -138,6 +143,15 @@ func (p *Processor) ReportResolve(ctx context.Context, account *gtsmodel.Account  		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)  	if err != nil {  		return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index 391239abc..a4d4521ce 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -81,6 +81,9 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, clientMsg messages  		case ap.ObjectProfile, ap.ActorPerson:  			// UPDATE ACCOUNT/PROFILE  			return p.processUpdateAccountFromClientAPI(ctx, clientMsg) +		case ap.ActivityFlag: +			// UPDATE A FLAG/REPORT (mark as resolved/closed) +			return p.processUpdateReportFromClientAPI(ctx, clientMsg)  		}  	case ap.ActivityAccept:  		// ACCEPT @@ -240,6 +243,21 @@ func (p *Processor) processUpdateAccountFromClientAPI(ctx context.Context, clien  	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 {  	follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow)  	if !ok { @@ -349,14 +367,17 @@ func (p *Processor) processReportAccountFromClientAPI(ctx context.Context, clien  		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 { +			return fmt.Errorf("processReportAccountFromClientAPI: error federating report: %w", err) +		} +	} -	if !*report.Forwarded { -		// nothing to do, don't federate the report -		return nil +	if err := p.notifyReport(ctx, report); err != nil { +		return fmt.Errorf("processReportAccountFromClientAPI: error notifying report: %w", err)  	} -	return p.federateReport(ctx, report) +	return nil  }  // TODO: move all the below functions into federation.Federator diff --git a/internal/processing/fromcommon.go b/internal/processing/fromcommon.go index ccdeca3c5..c29ada5ba 100644 --- a/internal/processing/fromcommon.go +++ b/internal/processing/fromcommon.go @@ -19,11 +19,14 @@ package processing  import (  	"context" +	"errors"  	"fmt"  	"strings"  	"sync" +	"github.com/superseriousbusiness/gotosocial/internal/config"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/email"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/id"  	"github.com/superseriousbusiness/gotosocial/internal/stream" @@ -308,6 +311,96 @@ func (p *Processor) notifyAnnounce(ctx context.Context, status *gtsmodel.Status)  	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  // the HOME timelines of accounts that follow the status author.  func (p *Processor) timelineStatus(ctx context.Context, status *gtsmodel.Status) error { diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go index 014c4a324..32a970114 100644 --- a/internal/processing/fromfederator.go +++ b/internal/processing/fromfederator.go @@ -359,10 +359,15 @@ func (p *Processor) processCreateBlockFromFederator(ctx context.Context, federat  }  func (p *Processor) processCreateFlagFromFederator(ctx context.Context, federatorMsg messages.FromFederator) error { -	// TODO: handle side effects of flag creation: -	// - send email to admins -	// - notify admins -	return nil +	incomingReport, ok := federatorMsg.GTSModel.(*gtsmodel.Report) +	if !ok { +		return errors.New("flag was not parseable as *gtsmodel.Report") +	} + +	// 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 diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 98b417ba3..ad485b9ae 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -48,6 +48,7 @@ type Processor struct {  	statusTimelines timeline.Manager  	state           *state.State  	filter          visibility.Filter +	emailSender     email.Sender  	/*  		SUB-PROCESSORS @@ -119,8 +120,9 @@ func NewProcessor(  			StatusPrepareFunction(state.DB, tc),  			StatusSkipInsertFunction(),  		), -		state:  state, -		filter: filter, +		state:       state, +		filter:      filter, +		emailSender: emailSender,  	}  	// sub processors  | 
