[server] Add optional TLS/SSL encryption for SMTP (#6863)

## Description

Implement TLS/SSL encryption for sending emails via SMTP. When an SMTP
provider explicitly requires TLS/SSL communication the current
implementation runs in a timeout and fails. A new configuration
parameter for smtp was added to enable TLS/SSL communication.

This would solve #5958 

## Tests

I built a local docker image of my branch. The email provider I was
using is mailbox.org and using the tls configuration. Registering a new
user then resulted in a sent email containing the verification code.

I did not test a setup without TLS/SSL.
This commit is contained in:
Neeraj
2025-08-20 11:00:36 +05:30
committed by GitHub
6 changed files with 138 additions and 4 deletions

View File

@@ -142,6 +142,22 @@ var _updateFreeUserStorage = &cobra.Command{
}, },
} }
var _sendMail = &cobra.Command{
Use: "send-mail <to-email> <from-email> <from-name>",
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() { func init() {
rootCmd.AddCommand(_adminCmd) rootCmd.AddCommand(_adminCmd)
_ = _userDetailsCmd.MarkFlagRequired("admin-user") _ = _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)") _updateFreeUserStorage.Flags().StringP("user", "u", "", "The email of the user to update subscription for. (required)")
// add a flag with no value --no-limit // 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") _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)
} }

View File

@@ -139,5 +139,28 @@ func (c *Client) UpdateFreePlanSub(ctx context.Context, userDetails *models.User
} }
} }
return nil 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
} }

View File

@@ -156,6 +156,23 @@ func (c *ClICtrl) UpdateFreeStorage(ctx context.Context, params model.AdminActio
return nil 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) { func (c *ClICtrl) buildAdminContext(ctx context.Context, adminEmail string) (context.Context, error) {
accounts, err := c.GetAccounts(ctx) accounts, err := c.GetAccounts(ctx)
if err != nil { if err != nil {

View File

@@ -171,16 +171,19 @@ smtp:
email: email:
# Optional name for sender # Optional name for sender
sender-name: sender-name:
# Optional encryption
encryption:
``` ```
| Variable | Description | Default | | Variable | Description | Default |
| ------------------ | ---------------------------- | ------- | |--------------------|------------------------------| ------- |
| `smtp.host` | SMTP server host | | | `smtp.host` | SMTP server host | |
| `smtp.port` | SMTP server port | | | `smtp.port` | SMTP server port | |
| `smtp.username` | SMTP auth username | | | `smtp.username` | SMTP auth username | |
| `smtp.password` | SMTP auth password | | | `smtp.password` | SMTP auth password | |
| `smtp.email` | Sender email address | | | `smtp.email` | Sender email address | |
| `smtp.sender-name` | Custom name for email sender | | | `smtp.sender-name` | Custom name for email sender | |
| `smtp.encryption` | Encryption method (tls, ssl) | |
| `transmail.key` | Zeptomail API key | | | `transmail.key` | Zeptomail API key | |
### WebAuthn Passkey Support ### WebAuthn Passkey Support

View File

@@ -255,6 +255,8 @@ smtp:
# Optional override for the sender name in the emails. If specified, it will # 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). # be used for all emails sent by the instance (default is email specific).
sender-name: sender-name:
# Optional encryption method. If tls or ssl is chosen, encryption is enabled.
encryption:
# Zoho Zeptomail config (optional) # Zoho Zeptomail config (optional)
# #

View File

@@ -7,6 +7,7 @@ package email
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
@@ -47,6 +48,7 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s
smtpPassword := viper.GetString("smtp.password") smtpPassword := viper.GetString("smtp.password")
smtpEmail := viper.GetString("smtp.email") smtpEmail := viper.GetString("smtp.email")
smtpSenderName := viper.GetString("smtp.sender-name") smtpSenderName := viper.GetString("smtp.sender-name")
smtpEncryption := viper.GetString("smtp.encryption")
var emailMessage string var emailMessage string
var auth smtp.Auth = nil 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 // Send the email to each recipient
for _, toEmail := range toEmails { 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 { if err != nil {
errMsg := err.Error() errMsg := err.Error()
for i := range knownInvalidEmailErrors { for i := range knownInvalidEmailErrors {
@@ -119,6 +121,76 @@ func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject s
return nil 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 { func sendViaTransmail(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error {
if len(toEmails) == 0 { if len(toEmails) == 0 {
return ente.ErrBadRequest return ente.ErrBadRequest