From 7db81cde444f6bc95e79527af0997de1788d48c7 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Sun, 19 Mar 2023 13:11:46 +0100 Subject: [PATCH] [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 --- docs/configuration/smtp.md | 24 ++- example/config.yaml | 12 ++ internal/config/config.go | 11 +- internal/config/defaults.go | 11 +- internal/config/flags.go | 1 + internal/config/helpers.gen.go | 25 +++ internal/db/bundb/instance.go | 31 ++++ internal/db/bundb/instance_test.go | 38 +++++ internal/db/instance.go | 4 + internal/email/common.go | 112 +++++++++++++ internal/email/confirm.go | 26 +-- internal/email/email_test.go | 152 ++++++++++++++++++ internal/email/noopsender.go | 58 +++---- internal/email/report.go | 64 ++++++++ internal/email/reset.go | 26 +-- internal/email/sender.go | 11 ++ internal/email/test.go | 26 +-- internal/email/util.go | 71 -------- internal/email/util_test.go | 59 ------- internal/processing/admin/report.go | 16 +- internal/processing/fromclientapi.go | 33 +++- internal/processing/fromcommon.go | 93 +++++++++++ internal/processing/fromfederator.go | 13 +- internal/processing/processor.go | 6 +- test/envparsing.sh | 2 + testrig/config.go | 11 +- web/template/email_confirm.tmpl | 28 ++++ web/template/email_confirm_html.tmpl | 47 ------ web/template/email_confirm_text.tmpl | 27 ---- web/template/email_new_report.tmpl | 26 +++ web/template/email_report_closed.tmpl | 27 ++++ web/template/email_reset.tmpl | 28 ++++ web/template/email_reset_html.tmpl | 47 ------ web/template/email_reset_text.tmpl | 27 ---- .../{email_test_text.tmpl => email_test.tmpl} | 0 35 files changed, 773 insertions(+), 420 deletions(-) create mode 100644 internal/email/common.go create mode 100644 internal/email/report.go delete mode 100644 internal/email/util.go delete mode 100644 internal/email/util_test.go create mode 100644 web/template/email_confirm.tmpl delete mode 100644 web/template/email_confirm_html.tmpl delete mode 100644 web/template/email_confirm_text.tmpl create mode 100644 web/template/email_new_report.tmpl create mode 100644 web/template/email_report_closed.tmpl create mode 100644 web/template/email_reset.tmpl delete mode 100644 web/template/email_reset_html.tmpl delete mode 100644 web/template/email_reset_text.tmpl rename web/template/{email_test_text.tmpl => email_test.tmpl} (100%) diff --git a/docs/configuration/smtp.md b/docs/configuration/smtp.md index b87847abd..d707fb291 100644 --- 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 index 60680fd25..cb21e733b 100644 --- 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 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 . + +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 - } - - log.Tracef(nil, "NOT SENDING reset email to %s with contents: %s", toAddress, msg) - - if s.sendCallback != nil { - s.sendCallback(toAddress, string(msg)) - } - - return nil + return s.sendTemplate(resetTemplate, resetSubject, data, toAddress) } func (s *noopSender) SendTestEmail(toAddress string, data TestData) error { + return s.sendTemplate(testTemplate, testSubject, data, toAddress) +} + +func (s *noopSender) SendNewReportEmail(toAddresses []string, data NewReportData) error { + return s.sendTemplate(newReportTemplate, newReportSubject, data, toAddresses...) +} + +func (s *noopSender) SendReportClosedEmail(toAddress string, data ReportClosedData) error { + return s.sendTemplate(reportClosedTemplate, reportClosedSubject, data, toAddress) +} + +func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error { buf := &bytes.Buffer{} - 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 . + +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 . - -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 . - -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 { - // nothing to do, don't federate the report - return nil + if *report.Forwarded { + if err := p.federateReport(ctx, report); err != nil { + return fmt.Errorf("processReportAccountFromClientAPI: error federating report: %w", err) + } } - return p.federateReport(ctx, report) + if err := p.notifyReport(ctx, report); err != nil { + return fmt.Errorf("processReportAccountFromClientAPI: error notifying report: %w", err) + } + + return nil } // TODO: move all the below functions into federation.Federator 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 diff --git a/test/envparsing.sh b/test/envparsing.sh index 9f16e026c..361866881 100755 --- 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 index f960d9901..df9efd54f 100644 --- 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 new file mode 100644 index 000000000..17926fdde --- /dev/null +++ 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 . +*/ -}} + +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 deleted file mode 100644 index 3fb9b234b..000000000 --- a/web/template/email_confirm_html.tmpl +++ /dev/null @@ -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 . -*/ -}} - - - - - -
-

- Hello {{.Username}}! -

-
-
-

- You are receiving this mail because you've requested an account on {{.InstanceName}}. -

-

- We just need to confirm that this is your email address. To confirm your email, click here or 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 {{.InstanceName}}. -

-
- - \ No newline at end of file diff --git a/web/template/email_confirm_text.tmpl b/web/template/email_confirm_text.tmpl deleted file mode 100644 index 738f6fd37..000000000 --- a/web/template/email_confirm_text.tmpl +++ /dev/null @@ -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 . -*/ -}} - -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 new file mode 100644 index 000000000..af98579c4 --- /dev/null +++ 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 . +*/ -}} + +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 new file mode 100644 index 000000000..878e5b63f --- /dev/null +++ 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 . +*/ -}} + +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 new file mode 100644 index 000000000..789470efc --- /dev/null +++ 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 . +*/ -}} + +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 deleted file mode 100644 index 4da26de15..000000000 --- a/web/template/email_reset_html.tmpl +++ /dev/null @@ -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 . -*/ -}} - - - - - -
-

- Hello {{.Username}}! -

-
-
-

- You are receiving this mail because a password reset has been requested for your account on {{.InstanceName}}. -

-

- To reset your password, click here or 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 {{.InstanceName}}. -

-
- - \ No newline at end of file diff --git a/web/template/email_reset_text.tmpl b/web/template/email_reset_text.tmpl deleted file mode 100644 index 378bf3f7e..000000000 --- a/web/template/email_reset_text.tmpl +++ /dev/null @@ -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 . -*/ -}} - -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 similarity index 100% rename from web/template/email_test_text.tmpl rename to web/template/email_test.tmpl