From 196cd88b1c7c44a337ca12f6a804f1bb7fa83c4a Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Tue, 14 Mar 2023 17:11:04 +0100
Subject: [PATCH] [feature] Allow admins to send test emails (#1620)
* [feature] Allow admins to send test emails
* implement unwrap on new error type
* add + use gtserror types
* GoToSocial Email Test -> GoToSocial Test Email
* add + use getInstance db call
* removed unused "unknown" error type
---
docs/api/swagger.yaml | 40 +++++++++
internal/api/client/admin/admin.go | 80 +++++++----------
internal/api/client/admin/emailtest.go | 120 +++++++++++++++++++++++++
internal/api/model/admin.go | 6 ++
internal/db/bundb/instance.go | 14 +++
internal/db/bundb/instance_test.go | 13 +++
internal/db/instance.go | 3 +
internal/email/confirm.go | 37 ++++----
internal/email/noopsender.go | 21 +++++
internal/email/reset.go | 35 +++++---
internal/email/sender.go | 3 +
internal/email/test.go | 58 ++++++++++++
internal/gtserror/error.go | 21 +++++
internal/processing/admin/admin.go | 5 +-
internal/processing/admin/email.go | 61 +++++++++++++
internal/processing/processor.go | 2 +-
web/template/email_test_text.tmpl | 24 +++++
17 files changed, 460 insertions(+), 83 deletions(-)
create mode 100644 internal/api/client/admin/emailtest.go
create mode 100644 internal/email/test.go
create mode 100644 internal/processing/admin/email.go
create mode 100644 web/template/email_test_text.tmpl
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 28e17d580..e0a61012a 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -3695,6 +3695,46 @@ paths:
summary: View domain block with the given ID.
tags:
- admin
+ /api/v1/admin/email/test:
+ post:
+ consumes:
+ - multipart/form-data
+ description: |-
+ This can be used to validate an instance's SMTP configuration, and to debug any potential issues.
+
+ If an error is returned by the SMTP connection, this handler will return code 422 to indicate that
+ the request could not be processed, and the SMTP error will be returned to the caller.
+ operationId: testEmailSend
+ parameters:
+ - description: The email address that the test email should be sent to.
+ in: formData
+ name: email
+ type: string
+ produces:
+ - application/json
+ responses:
+ "202":
+ description: Test email was sent.
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "403":
+ description: forbidden
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "422":
+ description: An smtp occurred while the email attempt was in progress. Check the returned json for more information. The smtp error will be included, to help you debug communication with the smtp server.
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - admin
+ summary: Send a generic test email to a specified email address.
+ tags:
+ - admin
/api/v1/admin/media_cleanup:
post:
consumes:
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go
index 2193ce545..4079dd979 100644
--- a/internal/api/client/admin/admin.go
+++ b/internal/api/client/admin/admin.go
@@ -25,60 +25,37 @@ import (
)
const (
- // BasePath is the base API path for this module, excluding the api prefix
- BasePath = "/v1/admin"
- // EmojiPath is used for posting/deleting custom emojis.
- EmojiPath = BasePath + "/custom_emojis"
- // EmojiPathWithID is used for interacting with a single emoji.
- EmojiPathWithID = EmojiPath + "/:" + IDKey
- // EmojiCategoriesPath is used for interacting with emoji categories.
- EmojiCategoriesPath = EmojiPath + "/categories"
- // DomainBlocksPath is used for posting domain blocks.
- DomainBlocksPath = BasePath + "/domain_blocks"
- // DomainBlocksPathWithID is used for interacting with a single domain block.
+ BasePath = "/v1/admin"
+ EmojiPath = BasePath + "/custom_emojis"
+ EmojiPathWithID = EmojiPath + "/:" + IDKey
+ EmojiCategoriesPath = EmojiPath + "/categories"
+ DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
- // AccountsPath is used for listing + acting on accounts.
- AccountsPath = BasePath + "/accounts"
- // AccountsPathWithID is used for interacting with a single account.
- AccountsPathWithID = AccountsPath + "/:" + IDKey
- // AccountsActionPath is used for taking action on a single account.
- AccountsActionPath = AccountsPathWithID + "/action"
- MediaCleanupPath = BasePath + "/media_cleanup"
- MediaRefetchPath = BasePath + "/media_refetch"
- // ReportsPath is for serving admin view of user reports.
- ReportsPath = BasePath + "/reports"
- // ReportsPathWithID is for viewing/acting on one report.
- ReportsPathWithID = ReportsPath + "/:" + IDKey
- // ReportsResolvePath is for marking one report as resolved.
- ReportsResolvePath = ReportsPathWithID + "/resolve"
+ AccountsPath = BasePath + "/accounts"
+ AccountsPathWithID = AccountsPath + "/:" + IDKey
+ AccountsActionPath = AccountsPathWithID + "/action"
+ MediaCleanupPath = BasePath + "/media_cleanup"
+ MediaRefetchPath = BasePath + "/media_refetch"
+ ReportsPath = BasePath + "/reports"
+ ReportsPathWithID = ReportsPath + "/:" + IDKey
+ ReportsResolvePath = ReportsPathWithID + "/resolve"
+ EmailPath = BasePath + "/email"
+ EmailTestPath = EmailPath + "/test"
- // ExportQueryKey is for requesting a public export of some data.
- ExportQueryKey = "export"
- // ImportQueryKey is for submitting an import of some data.
- ImportQueryKey = "import"
- // IDKey specifies the ID of a single item being interacted with.
- IDKey = "id"
- // FilterKey is for applying filters to admin views of accounts, emojis, etc.
- FilterQueryKey = "filter"
- // MaxShortcodeDomainKey is the url query for returning emoji results lower (alphabetically)
- // than the given `[shortcode]@[domain]` parameter.
+ ExportQueryKey = "export"
+ ImportQueryKey = "import"
+ IDKey = "id"
+ FilterQueryKey = "filter"
MaxShortcodeDomainKey = "max_shortcode_domain"
- // MaxShortcodeDomainKey is the url query for returning emoji results higher (alphabetically)
- // than the given `[shortcode]@[domain]` parameter.
MinShortcodeDomainKey = "min_shortcode_domain"
- // LimitKey is for specifying maximum number of results to return.
- LimitKey = "limit"
- // DomainQueryKey is for specifying a domain during admin actions.
- DomainQueryKey = "domain"
- // ResolvedKey is for filtering reports by their resolved status
- ResolvedKey = "resolved"
- // AccountIDKey is for selecting account in API paths.
- AccountIDKey = "account_id"
- // TargetAccountIDKey is for selecting target account in API paths.
- TargetAccountIDKey = "target_account_id"
- MaxIDKey = "max_id"
- SinceIDKey = "since_id"
- MinIDKey = "min_id"
+ LimitKey = "limit"
+ DomainQueryKey = "domain"
+ ResolvedKey = "resolved"
+ AccountIDKey = "account_id"
+ TargetAccountIDKey = "target_account_id"
+ MaxIDKey = "max_id"
+ SinceIDKey = "since_id"
+ MinIDKey = "min_id"
)
type Module struct {
@@ -117,4 +94,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodGet, ReportsPath, m.ReportsGETHandler)
attachHandler(http.MethodGet, ReportsPathWithID, m.ReportGETHandler)
attachHandler(http.MethodPost, ReportsResolvePath, m.ReportResolvePOSTHandler)
+
+ // email stuff
+ attachHandler(http.MethodPost, EmailTestPath, m.EmailTestPOSTHandler)
}
diff --git a/internal/api/client/admin/emailtest.go b/internal/api/client/admin/emailtest.go
new file mode 100644
index 000000000..5c5330679
--- /dev/null
+++ b/internal/api/client/admin/emailtest.go
@@ -0,0 +1,120 @@
+// 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 admin
+
+import (
+ "fmt"
+ "net/http"
+ "net/mail"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// EmailTestPostHandler swagger:operation POST /api/v1/admin/email/test testEmailSend
+//
+// Send a generic test email to a specified email address.
+//
+// This can be used to validate an instance's SMTP configuration, and to debug any potential issues.
+//
+// If an error is returned by the SMTP connection, this handler will return code 422 to indicate that
+// the request could not be processed, and the SMTP error will be returned to the caller.
+//
+// ---
+// tags:
+// - admin
+//
+// consumes:
+// - multipart/form-data
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: email
+// in: formData
+// description: The email address that the test email should be sent to.
+// type: string
+//
+// security:
+// - OAuth2 Bearer:
+// - admin
+//
+// responses:
+// '202':
+// description: Test email was sent.
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '422':
+// description: >-
+// An smtp occurred while the email attempt was in progress.
+// Check the returned json for more information. The smtp error
+// will be included, to help you debug communication with the
+// smtp server.
+// '500':
+// description: internal server error
+func (m *Module) EmailTestPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if !*authed.User.Admin {
+ err := fmt.Errorf("user %s not an admin", authed.User.ID)
+ apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.AdminSendTestEmailRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ email, err := mail.ParseAddress(form.Email)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ errWithCode := m.processor.Admin().EmailTest(c.Request.Context(), authed.Account, email.Address)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusAccepted, gin.H{"status": "test email sent"})
+}
diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go
index 7d1590b34..cc449ab82 100644
--- a/internal/api/model/admin.go
+++ b/internal/api/model/admin.go
@@ -183,3 +183,9 @@ type MediaCleanupRequest struct {
// If value is not specified, the value of media-remote-cache-days in the server config will be used.
RemoteCacheDays *int `form:"remote_cache_days" json:"remote_cache_days" xml:"remote_cache_days"`
}
+
+// AdminSendTestEmailRequest models a test email send request (woah).
+type AdminSendTestEmailRequest struct {
+ // Email address to send the test email to.
+ Email string `form:"email" json:"email" xml:"email"`
+}
diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go
index c40551212..b4bdeb1d9 100644
--- a/internal/db/bundb/instance.go
+++ b/internal/db/bundb/instance.go
@@ -97,6 +97,20 @@ func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (i
return count, nil
}
+func (i *instanceDB) GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, db.Error) {
+ instance := >smodel.Instance{}
+
+ if err := i.conn.
+ NewSelect().
+ Model(instance).
+ Where("? = ?", bun.Ident("instance.domain"), domain).
+ Scan(ctx); err != nil {
+ return nil, i.conn.ProcessError(err)
+ }
+
+ return instance, nil
+}
+
func (i *instanceDB) GetInstancePeers(ctx context.Context, includeSuspended bool) ([]*gtsmodel.Instance, db.Error) {
instances := []*gtsmodel.Instance{}
diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go
index 3edb68370..4269df5ca 100644
--- a/internal/db/bundb/instance_test.go
+++ b/internal/db/bundb/instance_test.go
@@ -23,6 +23,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
)
type InstanceTestSuite struct {
@@ -59,6 +60,18 @@ func (suite *InstanceTestSuite) TestCountInstanceDomains() {
suite.Equal(2, count)
}
+func (suite *InstanceTestSuite) TestGetInstanceOK() {
+ instance, err := suite.db.GetInstance(context.Background(), "localhost:8080")
+ suite.NoError(err)
+ suite.NotNil(instance)
+}
+
+func (suite *InstanceTestSuite) TestGetInstanceNonexistent() {
+ instance, err := suite.db.GetInstance(context.Background(), "doesnt.exist.com")
+ suite.ErrorIs(err, db.ErrNoEntries)
+ suite.Nil(instance)
+}
+
func (suite *InstanceTestSuite) TestGetInstancePeers() {
peers, err := suite.db.GetInstancePeers(context.Background(), false)
suite.NoError(err)
diff --git a/internal/db/instance.go b/internal/db/instance.go
index 85d094d96..dff471193 100644
--- a/internal/db/instance.go
+++ b/internal/db/instance.go
@@ -34,6 +34,9 @@ type Instance interface {
// CountInstanceDomains returns the number of known instances known that the given domain federates with.
CountInstanceDomains(ctx context.Context, domain string) (int, Error)
+ // GetInstance returns the instance entry for the given domain, if it exists.
+ GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, Error)
+
// GetInstanceAccounts returns a slice of accounts from the given instance, arranged by ID.
GetInstanceAccounts(ctx context.Context, domain string, maxID string, limit int) ([]*gtsmodel.Account, Error)
diff --git a/internal/email/confirm.go b/internal/email/confirm.go
index 94aebc61f..a6548e7d1 100644
--- a/internal/email/confirm.go
+++ b/internal/email/confirm.go
@@ -21,8 +21,7 @@ import (
"bytes"
"net/smtp"
- "github.com/superseriousbusiness/gotosocial/internal/config"
- "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const (
@@ -30,21 +29,6 @@ const (
confirmSubject = "GoToSocial Email Confirmation"
)
-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
- }
- log.Trace(nil, s.hostAddress+"\n"+config.GetSMTPUsername()+":password"+"\n"+s.from+"\n"+toAddress+"\n\n"+string(msg)+"\n")
- return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg)
-}
-
// ConfirmData represents data passed into the confirm email address template.
type ConfirmData struct {
// Username to be addressed.
@@ -57,3 +41,22 @@ type ConfirmData struct {
// Should be a full link with protocol eg., https://example.org/confirm_email?token=some-long-token
ConfirmLink string
}
+
+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
+}
diff --git a/internal/email/noopsender.go b/internal/email/noopsender.go
index 435ffb04c..7164440f3 100644
--- a/internal/email/noopsender.go
+++ b/internal/email/noopsender.go
@@ -88,3 +88,24 @@ func (s *noopSender) SendResetEmail(toAddress string, data ResetData) error {
return nil
}
+
+func (s *noopSender) 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, "test@example.org")
+ if err != nil {
+ return err
+ }
+
+ log.Tracef(nil, "NOT SENDING test email to %s with contents: %s", toAddress, msg)
+
+ if s.sendCallback != nil {
+ s.sendCallback(toAddress, string(msg))
+ }
+
+ return nil
+}
diff --git a/internal/email/reset.go b/internal/email/reset.go
index 0b950c1c9..cb1da9fee 100644
--- a/internal/email/reset.go
+++ b/internal/email/reset.go
@@ -20,6 +20,8 @@ package email
import (
"bytes"
"net/smtp"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
const (
@@ -27,20 +29,6 @@ const (
resetSubject = "GoToSocial Password Reset"
)
-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
- }
- return smtp.SendMail(s.hostAddress, s.auth, s.from, []string{toAddress}, msg)
-}
-
// ResetData represents data passed into the reset email address template.
type ResetData struct {
// Username to be addressed.
@@ -53,3 +41,22 @@ type ResetData struct {
// Should be a full link with protocol eg., https://example.org/reset_password?token=some-reset-password-token
ResetLink string
}
+
+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
+}
diff --git a/internal/email/sender.go b/internal/email/sender.go
index 3d188011f..13dd26531 100644
--- a/internal/email/sender.go
+++ b/internal/email/sender.go
@@ -32,6 +32,9 @@ type Sender interface {
// SendResetEmail sends a 'reset your password' style email to the given toAddress, with the given data.
SendResetEmail(toAddress string, data ResetData) error
+
+ // SendTestEmail sends a 'testing email sending' style email to the given toAddress, with the given data.
+ SendTestEmail(toAddress string, data TestData) 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
new file mode 100644
index 000000000..1e411f161
--- /dev/null
+++ b/internal/email/test.go
@@ -0,0 +1,58 @@
+// 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"
+ "net/smtp"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+const (
+ testTemplate = "email_test_text.tmpl"
+ testSubject = "GoToSocial Test Email"
+)
+
+type TestData struct {
+ // Username of admin user who sent the test.
+ SendingUsername string
+ // URL of the instance to present to the receiver.
+ InstanceURL string
+ // Name of the instance to present to the receiver.
+ InstanceName string
+}
+
+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
+}
diff --git a/internal/gtserror/error.go b/internal/gtserror/error.go
index 981b987cb..56e546cf1 100644
--- a/internal/gtserror/error.go
+++ b/internal/gtserror/error.go
@@ -24,11 +24,18 @@ import (
// package private error key type.
type errkey int
+// ErrorType denotes the type of an error, if set.
+type ErrorType string
+
const (
// error value keys.
_ errkey = iota
statusCodeKey
notFoundKey
+ errorTypeKey
+
+ // error types
+ TypeSMTP ErrorType = "smtp" // smtp (mail) error
)
// StatusCode checks error for a stored status code value. For example
@@ -57,3 +64,17 @@ func NotFound(err error) bool {
func SetNotFound(err error) error {
return errors.WithValue(err, notFoundKey, struct{}{})
}
+
+// Type checks error for a stored "type" value. For example
+// an error from sending an email may set a value of "smtp"
+// to indicate this was an SMTP error.
+func Type(err error) ErrorType {
+ s, _ := errors.Value(err, errorTypeKey).(ErrorType)
+ return s
+}
+
+// SetType will wrap the given error to store a "type" value,
+// returning wrapped error. See Type() for example use-cases.
+func SetType(err error, errType ErrorType) error {
+ return errors.WithValue(err, errorTypeKey, errType)
+}
diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go
index 83dbc5f5b..ad0279dbf 100644
--- a/internal/processing/admin/admin.go
+++ b/internal/processing/admin/admin.go
@@ -18,6 +18,7 @@
package admin
import (
+ "github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/transport"
@@ -29,14 +30,16 @@ type Processor struct {
tc typeutils.TypeConverter
mediaManager media.Manager
transportController transport.Controller
+ emailSender email.Sender
}
// New returns a new admin processor.
-func New(state *state.State, tc typeutils.TypeConverter, mediaManager media.Manager, transportController transport.Controller) Processor {
+func New(state *state.State, tc typeutils.TypeConverter, mediaManager media.Manager, transportController transport.Controller, emailSender email.Sender) Processor {
return Processor{
state: state,
tc: tc,
mediaManager: mediaManager,
transportController: transportController,
+ emailSender: emailSender,
}
}
diff --git a/internal/processing/admin/email.go b/internal/processing/admin/email.go
new file mode 100644
index 000000000..88396db76
--- /dev/null
+++ b/internal/processing/admin/email.go
@@ -0,0 +1,61 @@
+// 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 admin
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// EmailTest sends a generic test email to the given toAddress (which
+// should be a valid email address). To help callers differentiate between
+// proper errors and the smtp errors they're likely fishing for, will return
+// 422 + help text on an SMTP error, or error 500 otherwise.
+func (p *Processor) EmailTest(ctx context.Context, account *gtsmodel.Account, toAddress string) gtserror.WithCode {
+ // Pull our instance entry from the database,
+ // so we can greet the email recipient nicely.
+ instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
+ if err != nil {
+ err = fmt.Errorf("SendConfirmEmail: error getting instance: %s", err)
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ testData := email.TestData{
+ SendingUsername: account.Username,
+ InstanceURL: instance.URI,
+ InstanceName: instance.Title,
+ }
+
+ if err := p.emailSender.SendTestEmail(toAddress, testData); err != nil {
+ if errorType := gtserror.Type(err); errorType == gtserror.TypeSMTP {
+ // An error occurred during the SMTP part.
+ // We should indicate this to the caller, as
+ // it will likely help them debug the issue.
+ return gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+ // An actual error has occurred.
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 6f5e7a124..98b417ba3 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -125,7 +125,7 @@ func NewProcessor(
// sub processors
processor.account = account.New(state, tc, mediaManager, oauthServer, federator, parseMentionFunc)
- processor.admin = admin.New(state, tc, mediaManager, federator.TransportController())
+ processor.admin = admin.New(state, tc, mediaManager, federator.TransportController(), emailSender)
processor.fedi = fedi.New(state, tc, federator)
processor.media = media.New(state, tc, mediaManager, federator.TransportController())
processor.report = report.New(state, tc)
diff --git a/web/template/email_test_text.tmpl b/web/template/email_test_text.tmpl
new file mode 100644
index 000000000..d7af4d161
--- /dev/null
+++ b/web/template/email_test_text.tmpl
@@ -0,0 +1,24 @@
+{{- /*
+// 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 .
+*/ -}}
+
+This is a test email from {{.InstanceName}} ({{.InstanceURL}}).
+
+If you're seeing this email, that means the SMTP configuration is correct!
+
+This email was sent by the admin user @{{.SendingUsername}}.