gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 7db81cde444f6bc95e79527af0997de1788d48c7
parent 9c55c07be90d5695ed6553182a31cc7634f2f97e
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date:   Sun, 19 Mar 2023 13:11:46 +0100

[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
Diffstat:
Mdocs/configuration/smtp.md | 24++++++++++++++++++++++--
Mexample/config.yaml | 12++++++++++++
Minternal/config/config.go | 11++++++-----
Minternal/config/defaults.go | 11++++++-----
Minternal/config/flags.go | 1+
Minternal/config/helpers.gen.go | 25+++++++++++++++++++++++++
Minternal/db/bundb/instance.go | 31+++++++++++++++++++++++++++++++
Minternal/db/bundb/instance_test.go | 38++++++++++++++++++++++++++++++++++++++
Minternal/db/instance.go | 4++++
Ainternal/email/common.go | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/email/confirm.go | 26++------------------------
Minternal/email/email_test.go | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/email/noopsender.go | 54++++++++++++++++--------------------------------------
Ainternal/email/report.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/email/reset.go | 26++------------------------
Minternal/email/sender.go | 11+++++++++++
Minternal/email/test.go | 26++------------------------
Dinternal/email/util.go | 71-----------------------------------------------------------------------
Dinternal/email/util_test.go | 59-----------------------------------------------------------
Minternal/processing/admin/report.go | 16+++++++++++++++-
Minternal/processing/fromclientapi.go | 31++++++++++++++++++++++++++-----
Minternal/processing/fromcommon.go | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/fromfederator.go | 13+++++++++----
Minternal/processing/processor.go | 6++++--
Mtest/envparsing.sh | 2++
Mtestrig/config.go | 11++++++-----
Aweb/template/email_confirm.tmpl | 28++++++++++++++++++++++++++++
Dweb/template/email_confirm_html.tmpl | 48------------------------------------------------
Dweb/template/email_confirm_text.tmpl | 27---------------------------
Aweb/template/email_new_report.tmpl | 26++++++++++++++++++++++++++
Aweb/template/email_report_closed.tmpl | 27+++++++++++++++++++++++++++
Aweb/template/email_reset.tmpl | 28++++++++++++++++++++++++++++
Dweb/template/email_reset_html.tmpl | 48------------------------------------------------
Dweb/template/email_reset_text.tmpl | 27---------------------------
Rweb/template/email_test_text.tmpl -> web/template/email_test.tmpl | 0
35 files changed, 770 insertions(+), 419 deletions(-)

diff --git a/docs/configuration/smtp.md b/docs/configuration/smtp.md @@ -45,6 +45,18 @@ smtp-password: "" # Examples: ["mail@example.org"] # Default: "" smtp-from: "" + +# Bool. If true, when an email is sent that has multiple recipients, each recipient +# will be included in the To field, so that each recipient can see who else got the +# email, and they can 'reply all' to the other recipients if they want to. +# +# If false, email will be sent to Undisclosed Recipients, and each recipient will not +# be able to see who else received the email. +# +# It might be useful to change this setting to 'true' if you want to be able to discuss +# new moderation reports with other admins by 'replying-all' to the notification email. +# Default: false +smtp-disclose-recipients: false ``` Note that if you don't set `Host`, then email sending via smtp will be disabled, and the other settings will be ignored. GoToSocial will still log (at trace level) emails that *would* have been sent if smtp was enabled. @@ -59,11 +71,19 @@ The exception to this requirement is if you're running your mail server (or brid ### When are emails sent? -Currently, emails are only sent to users to request email confirmation when a new account is created, or to serve password reset requests. More email functionality will probably be added later. +Currently, emails are sent: + +- To the provided email address of a new user to request email confirmation when a new account is created via the API. +- To all active instance moderators + admins when a new moderation report is received. By default, recipients are Bcc'd, but you can change this behavior with the setting `smtp-disclose-recipients`. +- To the creator of a report (on this instance) when the report is closed by a moderator. + +### Can I test if my SMTP configuration is correct? + +Yes, you can use the API to send a test email to yourself. Check the API documentation for the `/api/v1/admin/email/test` endpoint. ### 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 diff --git a/example/config.yaml b/example/config.yaml @@ -701,6 +701,18 @@ smtp-password: "" # Default: "" smtp-from: "" +# Bool. If true, when an email is sent that has multiple recipients, each recipient +# will be included in the To field, so that each recipient can see who else got the +# email, and they can 'reply all' to the other recipients if they want to. +# +# If false, email will be sent to Undisclosed Recipients, and each recipient will not +# be able to see who else received the email. +# +# It might be useful to change this setting to 'true' if you want to be able to discuss +# new moderation reports with other admins by 'replying-all' to the notification email. +# Default: false +smtp-disclose-recipients: false + ######################### ##### SYSLOG CONFIG ##### ######################### diff --git 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 := &gtsmodel.User{} + *testUser = *suite.testUsers["local_account_1"] + testUser.Moderator = testrig.TrueBool() + if err := suite.db.UpdateUser(context.Background(), testUser, "moderator"); err != nil { + suite.FailNow(err.Error()) + } + + addresses, err := suite.db.GetInstanceModeratorAddresses(context.Background()) + suite.NoError(err) + suite.EqualValues([]string{"admin@example.org", "zork@example.org"}, addresses) +} + +func (suite *InstanceTestSuite) TestGetInstanceModeratorAddressesNoAdmin() { + // Demote admin from admin + moderator roles. + testUser := &gtsmodel.User{} + *testUser = *suite.testUsers["admin_account"] + testUser.Admin = testrig.FalseBool() + testUser.Moderator = testrig.FalseBool() + if err := suite.db.UpdateUser(context.Background(), testUser, "admin", "moderator"); err != nil { + suite.FailNow(err.Error()) + } + + addresses, err := suite.db.GetInstanceModeratorAddresses(context.Background()) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Empty(addresses) +} + func TestInstanceTestSuite(t *testing.T) { suite.Run(t, new(InstanceTestSuite)) } diff --git 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/envparsing.sh b/test/envparsing.sh @@ -113,6 +113,7 @@ EXPECT=$(cat <<"EOF" "port": 6969, "protocol": "http", "request-id-header": "X-Trace-Id", + "smtp-disclose-recipients": true, "smtp-from": "queen.rip.in.piss@terfisland.org", "smtp-host": "example.com", "smtp-password": "hunter2", @@ -222,6 +223,7 @@ GTS_SMTP_PORT=4269 \ GTS_SMTP_USERNAME='sex-haver' \ GTS_SMTP_PASSWORD='hunter2' \ GTS_SMTP_FROM='queen.rip.in.piss@terfisland.org' \ +GTS_SMTP_DISCLOSE_RECIPIENTS=true \ GTS_SYSLOG_ENABLED=true \ GTS_SYSLOG_PROTOCOL='udp' \ GTS_SYSLOG_ADDRESS='127.0.0.1:6969' \ diff --git a/testrig/config.go b/testrig/config.go @@ -106,11 +106,12 @@ var testDefaults = config.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/web/template/email_confirm.tmpl b/web/template/email_confirm.tmpl @@ -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}} diff --git a/web/template/email_confirm_html.tmpl b/web/template/email_confirm_html.tmpl @@ -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> -\ No newline at end of file diff --git a/web/template/email_confirm_text.tmpl b/web/template/email_confirm_text.tmpl @@ -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}} diff --git a/web/template/email_new_report.tmpl b/web/template/email_new_report.tmpl @@ -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 }} diff --git a/web/template/email_report_closed.tmpl b/web/template/email_report_closed.tmpl @@ -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 }} diff --git a/web/template/email_reset.tmpl b/web/template/email_reset.tmpl @@ -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}}. diff --git a/web/template/email_reset_html.tmpl b/web/template/email_reset_html.tmpl @@ -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> -\ No newline at end of file diff --git a/web/template/email_reset_text.tmpl b/web/template/email_reset_text.tmpl @@ -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}}. diff --git a/web/template/email_test_text.tmpl b/web/template/email_test.tmpl