From 6b37cc46a533f27eed3e4c8e2664687dd6157c80 Mon Sep 17 00:00:00 2001 From: Kilian Hohm Date: Fri, 15 Aug 2025 15:48:41 +0200 Subject: [PATCH 1/3] Add optional TLS/SSL encryption for sending emails via SMTP --- server/pkg/utils/email/email.go | 74 ++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/server/pkg/utils/email/email.go b/server/pkg/utils/email/email.go index 6565f3774a..248eb24f2d 100644 --- a/server/pkg/utils/email/email.go +++ b/server/pkg/utils/email/email.go @@ -7,6 +7,7 @@ package email import ( "bytes" + "crypto/tls" "encoding/json" "fmt" "html/template" @@ -47,6 +48,7 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s smtpPassword := viper.GetString("smtp.password") smtpEmail := viper.GetString("smtp.email") smtpSenderName := viper.GetString("smtp.sender-name") + smtpEncryption := viper.GetString("smtp.encryption") var emailMessage string var auth smtp.Auth = nil @@ -104,7 +106,7 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s // Send the email to each recipient for _, toEmail := range toEmails { - err := smtp.SendMail(smtpServer+":"+smtpPort, auth, fromEmail, []string{toEmail}, []byte(emailMessage)) + err := sendMailWithEncryption(smtpServer, smtpPort, auth, fromEmail, []string{toEmail}, []byte(emailMessage), smtpEncryption) if err != nil { errMsg := err.Error() for i := range knownInvalidEmailErrors { @@ -119,6 +121,76 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s return nil } +// sendMailWithEncryption sends an email with the specified encryption type +// encryption can be one of: +// - "tls" or "ssl": Uses TLS/SSL encryption for the entire connection +// - "" (empty string) or any other value: No encryption +func sendMailWithEncryption(host, port string, auth smtp.Auth, from string, to []string, msg []byte, encryption string) error { + addr := host + ":" + port + + switch strings.ToLower(encryption) { + case "tls", "ssl": + // For TLS/SSL, establish a secure connection directly + tlsConfig := &tls.Config{ + ServerName: host, + } + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return stacktrace.Propagate(err, "failed to establish TLS connection") + } + defer conn.Close() + + client, err := smtp.NewClient(conn, host) + if err != nil { + return stacktrace.Propagate(err, "failed to create SMTP client over TLS") + } + defer client.Close() + + return sendWithClient(client, auth, from, to, msg) + + default: + // No encryption, use standard SendMail + return smtp.SendMail(addr, auth, from, to, msg) + } +} + +// sendWithClient sends an email using an established SMTP client +func sendWithClient(client *smtp.Client, auth smtp.Auth, from string, to []string, msg []byte) error { + if auth != nil { + if err := client.Auth(auth); err != nil { + return stacktrace.Propagate(err, "authentication failed") + } + } + + if err := client.Mail(from); err != nil { + return stacktrace.Propagate(err, "failed to set sender") + } + + for _, addr := range to { + if err := client.Rcpt(addr); err != nil { + return stacktrace.Propagate(err, "failed to add recipient") + } + } + + w, err := client.Data() + if err != nil { + return stacktrace.Propagate(err, "failed to create message writer") + } + + _, err = w.Write(msg) + if err != nil { + return stacktrace.Propagate(err, "failed to write message") + } + + err = w.Close() + if err != nil { + return stacktrace.Propagate(err, "failed to close message writer") + } + + err = client.Quit() + return stacktrace.Propagate(err, "") +} + func sendViaTransmail(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error { if len(toEmails) == 0 { return ente.ErrBadRequest From a3d3ee24f841a819c865c273de256ecbac927a38 Mon Sep 17 00:00:00 2001 From: Kilian Hohm Date: Fri, 15 Aug 2025 15:48:59 +0200 Subject: [PATCH 2/3] Document optional TLS/SSL encryption for sending emails via SMTP --- docs/docs/self-hosting/installation/config.md | 5 ++++- server/configurations/local.yaml | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/docs/self-hosting/installation/config.md b/docs/docs/self-hosting/installation/config.md index 7156046c73..0450c20db6 100644 --- a/docs/docs/self-hosting/installation/config.md +++ b/docs/docs/self-hosting/installation/config.md @@ -171,16 +171,19 @@ smtp: email: # Optional name for sender sender-name: + # Optional encryption + encryption: ``` | Variable | Description | Default | -| ------------------ | ---------------------------- | ------- | +|--------------------|------------------------------| ------- | | `smtp.host` | SMTP server host | | | `smtp.port` | SMTP server port | | | `smtp.username` | SMTP auth username | | | `smtp.password` | SMTP auth password | | | `smtp.email` | Sender email address | | | `smtp.sender-name` | Custom name for email sender | | +| `smtp.encryption` | Encryption method (tls, ssl) | | | `transmail.key` | Zeptomail API key | | ### WebAuthn Passkey Support diff --git a/server/configurations/local.yaml b/server/configurations/local.yaml index 70d7589280..7b43329365 100644 --- a/server/configurations/local.yaml +++ b/server/configurations/local.yaml @@ -248,6 +248,8 @@ smtp: # Optional override for the sender name in the emails. If specified, it will # be used for all emails sent by the instance (default is email specific). sender-name: + # Optional encryption method. If tls or ssl is chosen, encryption is enabled. + encryption: # Zoho Zeptomail config (optional) # From cf938eca91f6bfd8862a3a1fa8a325a6f81b3e96 Mon Sep 17 00:00:00 2001 From: Kilian Hohm Date: Sat, 16 Aug 2025 10:33:23 +0200 Subject: [PATCH 3/3] Add CLI command to send a test email via admin API --- cli/cmd/admin.go | 19 ++++++++++++++++++- cli/internal/api/admin.go | 25 ++++++++++++++++++++++++- cli/pkg/admin_actions.go | 17 +++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/cli/cmd/admin.go b/cli/cmd/admin.go index f56fea06eb..2a9974fdbd 100644 --- a/cli/cmd/admin.go +++ b/cli/cmd/admin.go @@ -142,6 +142,22 @@ var _updateFreeUserStorage = &cobra.Command{ }, } +var _sendMail = &cobra.Command{ + Use: "send-mail ", + Args: cobra.ExactArgs(3), + Short: "Sends a test mail via the admin api", + RunE: func(cmd *cobra.Command, args []string) error { + recoverWithLog() + var flags = &model.AdminActionForUser{} + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Name == "admin-user" { + flags.AdminEmail = f.Value.String() + } + }) + return ctrl.SendTestMail(context.Background(), *flags, args[0], args[1], args[2]) + }, +} + func init() { rootCmd.AddCommand(_adminCmd) _ = _userDetailsCmd.MarkFlagRequired("admin-user") @@ -159,5 +175,6 @@ func init() { _updateFreeUserStorage.Flags().StringP("user", "u", "", "The email of the user to update subscription for. (required)") // add a flag with no value --no-limit _updateFreeUserStorage.Flags().String("no-limit", "True", "When true, sets 100TB as storage limit, and expiry to current date + 100 years") - _adminCmd.AddCommand(_userDetailsCmd, _disable2faCmd, _disablePasskeyCmd, _updateFreeUserStorage, _listUsers, _deleteUser) + _sendMail.Flags().StringP("admin-user", "a", "", "The email of the admin user. ") + _adminCmd.AddCommand(_userDetailsCmd, _disable2faCmd, _disablePasskeyCmd, _updateFreeUserStorage, _listUsers, _deleteUser, _sendMail) } diff --git a/cli/internal/api/admin.go b/cli/internal/api/admin.go index 3511876cd1..a2e6115d7c 100644 --- a/cli/internal/api/admin.go +++ b/cli/internal/api/admin.go @@ -139,5 +139,28 @@ func (c *Client) UpdateFreePlanSub(ctx context.Context, userDetails *models.User } } return nil - +} + +func (c *Client) SendTestMail(ctx context.Context, toEmail, fromEmail, fromName string) error { + body := map[string]interface{}{ + "to": []string{toEmail}, + "fromName": fromName, + "fromEmail": fromEmail, + "subject": "Test mail from Ente", + "body": "This is a test mail from Ente", + } + r, err := c.restClient.R(). + SetContext(ctx). + SetBody(body). + Post("/admin/mail") + if err != nil { + return err + } + if r.IsError() { + return &ApiError{ + StatusCode: r.StatusCode(), + Message: r.String(), + } + } + return nil } diff --git a/cli/pkg/admin_actions.go b/cli/pkg/admin_actions.go index 123970ee7f..044014bdcd 100644 --- a/cli/pkg/admin_actions.go +++ b/cli/pkg/admin_actions.go @@ -156,6 +156,23 @@ func (c *ClICtrl) UpdateFreeStorage(ctx context.Context, params model.AdminActio return nil } +func (c *ClICtrl) SendTestMail(ctx context.Context, params model.AdminActionForUser, to, from, fromName string) error { + accountCtx, err := c.buildAdminContext(ctx, params.AdminEmail) + if err != nil { + return err + } + err = c.Client.SendTestMail(accountCtx, to, from, fromName) + if err != nil { + if apiErr, ok := err.(*api.ApiError); ok && apiErr.StatusCode == 400 && strings.Contains(apiErr.Message, "Token is too old") { + fmt.Printf("Error: old admin token, please re-authenticate using `ente account add` \n") + return nil + } + return err + } + fmt.Printf("Successfully sent test email to %s\n", to) + return nil +} + func (c *ClICtrl) buildAdminContext(ctx context.Context, adminEmail string) (context.Context, error) { accounts, err := c.GetAccounts(ctx) if err != nil {