Tobias Genannt d25420c867 [server] Optional SMTP authentication
When using a local relay server, not authentication is needed. To
support this use-case authentication is only send to the server when an
user and password are set in the configuration file.
2024-09-15 15:15:10 +02:00

195 lines
5.8 KiB
Go

// The email package contains functions for directly sending emails.
//
// These functions can be used for directly sending emails to given email
// addresses. This is used for transactional emails, for example OTP requests.
// Currently, we use Zoho Transmail to send out the actual mail.
package email
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/smtp"
"strings"
"github.com/ente-io/museum/ente"
"github.com/ente-io/stacktrace"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
var knownInvalidEmailErrors = []string{
"Invalid RCPT TO address provided",
"Invalid domain name",
}
// Send sends an email
func Send(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error {
smtpHost := viper.GetString("smtp.host")
if smtpHost != "" {
return sendViaSMTP(toEmails, fromName, fromEmail, subject, htmlBody, inlineImages)
} else {
return sendViaTransmail(toEmails, fromName, fromEmail, subject, htmlBody, inlineImages)
}
}
func sendViaSMTP(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error {
if len(toEmails) == 0 {
return ente.ErrBadRequest
}
smtpServer := viper.GetString("smtp.host")
smtpPort := viper.GetString("smtp.port")
smtpUsername := viper.GetString("smtp.username")
smtpPassword := viper.GetString("smtp.password")
smtpEmail := viper.GetString("smtp.email")
var emailMessage string
var auth smtp.Auth = nil
if smtpUsername != "" && smtpPassword != "" {
auth = smtp.PlainAuth("", smtpUsername, smtpPassword, smtpServer)
}
// Construct 'emailAddresses' with comma-separated email addresses
var emailAddresses string
for i, email := range toEmails {
if i != 0 {
emailAddresses += ","
}
emailAddresses += email
}
// If an sender email is provided use it instead of the fromEmail.
if smtpEmail != "" {
fromEmail = smtpEmail
}
header := "From: " + fromName + " <" + fromEmail + ">\n" +
"To: " + emailAddresses + "\n" +
"Subject: " + subject + "\n" +
"MIME-Version: 1.0\n" +
"Content-Type: multipart/related; boundary=boundary\n\n" +
"--boundary\n"
htmlContent := "Content-Type: text/html; charset=us-ascii\n\n" + htmlBody + "\n"
emailMessage = header + htmlContent
if inlineImages == nil {
emailMessage += "--boundary--"
} else {
for _, inlineImage := range inlineImages {
emailMessage += "--boundary\n"
var mimeType = inlineImage["mime_type"].(string)
var contentID = inlineImage["cid"].(string)
var imgBase64Str = inlineImage["content"].(string)
var image = "Content-Type: " + mimeType + "\n" +
"Content-Transfer-Encoding: base64\n" +
"Content-ID: <" + contentID + ">\n" +
"Content-Disposition: inline\n\n" + imgBase64Str + "\n"
emailMessage += image
}
emailMessage += "--boundary--"
}
// Send the email to each recipient
for _, toEmail := range toEmails {
err := smtp.SendMail(smtpServer+":"+smtpPort, auth, fromEmail, []string{toEmail}, []byte(emailMessage))
if err != nil {
errMsg := err.Error()
for i := range knownInvalidEmailErrors {
if strings.Contains(errMsg, knownInvalidEmailErrors[i]) {
return stacktrace.Propagate(ente.NewBadRequestWithMessage(fmt.Sprintf("Invalid email %s", toEmail)), errMsg)
}
}
return stacktrace.Propagate(err, "")
}
}
return nil
}
func sendViaTransmail(toEmails []string, fromName string, fromEmail string, subject string, htmlBody string, inlineImages []map[string]interface{}) error {
if len(toEmails) == 0 {
return ente.ErrBadRequest
}
authKey := viper.GetString("transmail.key")
silent := viper.GetBool("internal.silent")
if authKey == "" || silent {
log.Infof("Skipping sending email to %s: %s", toEmails[0], subject)
return nil
}
var to []ente.ToEmailAddress
for _, toEmail := range toEmails {
to = append(to, ente.ToEmailAddress{EmailAddress: ente.EmailAddress{Address: toEmail}})
}
mail := &ente.Mail{
BounceAddress: ente.TransmailEndBounceAddress,
From: ente.EmailAddress{Address: fromEmail, Name: fromName},
Subject: subject,
Htmlbody: htmlBody,
InlineImages: inlineImages,
}
if len(toEmails) == 1 {
mail.To = to
} else {
mail.Bcc = to
}
postBody, err := json.Marshal(mail)
if err != nil {
return stacktrace.Propagate(err, "")
}
reqBody := bytes.NewBuffer(postBody)
client := &http.Client{}
req, err := http.NewRequest("POST", ente.TransmailEndPoint, reqBody)
if err != nil {
return stacktrace.Propagate(err, "")
}
req.Header.Set("accept", "application/json")
req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", authKey)
_, err = client.Do(req)
return stacktrace.Propagate(err, "")
}
func SendTemplatedEmail(to []string, fromName string, fromEmail string, subject string, templateName string, templateData map[string]interface{}, inlineImages []map[string]interface{}) error {
body, err := getMailBody(templateName, templateData)
if err != nil {
return stacktrace.Propagate(err, "")
}
return Send(to, fromName, fromEmail, subject, body, inlineImages)
}
func GetMaskedEmail(email string) string {
at := strings.LastIndex(email, "@")
if at >= 0 {
username, domain := email[:at], email[at+1:]
maskedUsername := ""
for i := 0; i < len(username); i++ {
maskedUsername += "*"
}
return maskedUsername + "@" + domain
} else {
// Should ideally never happen, there should always be an @ symbol
return "[invalid_email]"
}
}
// getMailBody generates the mail html body from provided template and data
func getMailBody(templateName string, templateData map[string]interface{}) (string, error) {
htmlbody := new(bytes.Buffer)
t := template.Must(template.New(templateName).ParseFiles("mail-templates/" + templateName))
err := t.Execute(htmlbody, templateData)
if err != nil {
return "", stacktrace.Propagate(err, "")
}
return htmlbody.String(), nil
}