github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/mails/mail.go (about)

     1  package mails
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"runtime"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/cozy/cozy-stack/model/instance"
    12  	"github.com/cozy/cozy-stack/model/job"
    13  	"github.com/cozy/cozy-stack/pkg/config/config"
    14  	"github.com/cozy/cozy-stack/pkg/mail"
    15  	"github.com/cozy/cozy-stack/pkg/utils"
    16  	"github.com/cozy/gomail"
    17  )
    18  
    19  func init() {
    20  	job.AddWorker(&job.WorkerConfig{
    21  		WorkerType:  "sendmail",
    22  		Concurrency: runtime.NumCPU(),
    23  		WorkerFunc:  SendMail,
    24  	})
    25  	initMailTemplates()
    26  }
    27  
    28  // var for testability
    29  var mailTemplater MailTemplater
    30  var sendMail = doSendMail
    31  
    32  // SendMail is the sendmail worker function.
    33  func SendMail(ctx *job.TaskContext) error {
    34  	opts := mail.Options{}
    35  	err := ctx.UnmarshalMessage(&opts)
    36  	if err != nil {
    37  		return err
    38  	}
    39  
    40  	from := config.GetConfig().NoReplyAddr
    41  	name := config.GetConfig().NoReplyName
    42  	replyTo := config.GetConfig().ReplyTo
    43  	if from == "" {
    44  		from = "noreply@" + utils.StripPort(ctx.Instance.Domain)
    45  	}
    46  	if ctxSettings, ok := ctx.Instance.SettingsContext(); ok {
    47  		if addr, ok := ctxSettings["noreply_address"].(string); ok && addr != "" {
    48  			from = addr
    49  		}
    50  		if nname, ok := ctxSettings["noreply_name"].(string); ok && nname != "" {
    51  			name = nname
    52  		}
    53  		if reply, ok := ctxSettings["reply_to"].(string); ok && reply != "" {
    54  			replyTo = reply
    55  		}
    56  	}
    57  
    58  	var cfgPerContext map[string]interface{}
    59  	if opts.Mode == mail.ModeCampaign {
    60  		cfgPerContext = config.GetConfig().CampaignMailPerContext
    61  	} else {
    62  		cfgPerContext = config.GetConfig().MailPerContext
    63  	}
    64  
    65  	ctxName := ctx.Instance.ContextName
    66  	if ctxConfig, ok := cfgPerContext[ctxName].(map[string]interface{}); ok {
    67  		if host, ok := ctxConfig["host"].(string); ok && host != "" {
    68  			port, _ := ctxConfig["port"].(int)
    69  			username, _ := ctxConfig["username"].(string)
    70  			password, _ := ctxConfig["username"].(string)
    71  			UseSSL, _ := ctxConfig["use_ssl"].(bool)
    72  			disableTLS, _ := ctxConfig["disable_tls"].(bool)
    73  			skipCertValid, _ := ctxConfig["skip_certificate_validation"].(bool)
    74  			LocalName, _ := ctxConfig["local_name"].(string)
    75  
    76  			opts.Dialer = &gomail.DialerOptions{
    77  				Host:                      host,
    78  				Port:                      port,
    79  				Username:                  username,
    80  				Password:                  password,
    81  				NativeTLS:                 UseSSL,
    82  				DisableTLS:                disableTLS,
    83  				SkipCertificateValidation: skipCertValid,
    84  				LocalName:                 LocalName,
    85  			}
    86  		}
    87  	}
    88  
    89  	switch opts.Mode {
    90  	case mail.ModeFromStack, mail.ModeCampaign:
    91  		toAddr, err := addressFromInstance(ctx.Instance)
    92  		if err != nil {
    93  			return err
    94  		}
    95  		opts.To = []*mail.Address{toAddr}
    96  		opts.From = &mail.Address{Name: name, Email: from}
    97  		if replyTo != "" {
    98  			opts.ReplyTo = &mail.Address{Name: name, Email: replyTo}
    99  		}
   100  		opts.RecipientName = toAddr.Name
   101  	case mail.ModePendingEmail:
   102  		toAddr, err := pendingAddress(ctx.Instance)
   103  		if err != nil {
   104  			return err
   105  		}
   106  		opts.To = []*mail.Address{toAddr}
   107  		opts.From = &mail.Address{Name: name, Email: from}
   108  		if replyTo != "" {
   109  			opts.ReplyTo = &mail.Address{Name: name, Email: replyTo}
   110  		}
   111  		opts.RecipientName = toAddr.Name
   112  	case mail.ModeFromUser:
   113  		sender, err := addressFromInstance(ctx.Instance)
   114  		if err != nil {
   115  			return err
   116  		}
   117  		name = sender.Name
   118  		opts.ReplyTo = sender
   119  		opts.From = &mail.Address{Name: name, Email: from}
   120  	case mail.ModeSupport:
   121  		toAddr, err := addressFromInstance(ctx.Instance)
   122  		if err != nil {
   123  			return err
   124  		}
   125  		opts.To = []*mail.Address{toAddr}
   126  		opts.From = &mail.Address{Name: name, Email: from}
   127  		if replyTo != "" {
   128  			opts.ReplyTo = &mail.Address{Name: name, Email: replyTo}
   129  		}
   130  		opts.RecipientName = toAddr.Name
   131  		if err := sendSupportMail(ctx, &opts, ctx.Instance.Domain); err != nil {
   132  			return err
   133  		}
   134  	default:
   135  		return fmt.Errorf("Mail sent with unknown mode %s", opts.Mode)
   136  	}
   137  	if opts.TemplateName != "" && opts.Locale == "" {
   138  		opts.Locale = ctx.Instance.Locale
   139  	}
   140  	if err = sendMail(ctx, &opts, ctx.Instance.Domain); err != nil {
   141  		ctx.Logger().Warnf("sendmail has failed: %s", err)
   142  	}
   143  	return err
   144  }
   145  
   146  func pendingAddress(i *instance.Instance) (*mail.Address, error) {
   147  	doc, err := i.SettingsDocument()
   148  	if err != nil {
   149  		return nil, err
   150  	}
   151  	email, ok := doc.M["pending_email"].(string)
   152  	if !ok {
   153  		return nil, fmt.Errorf("Domain %s has no pending email in its settings", i.Domain)
   154  	}
   155  	publicName, _ := doc.M["public_name"].(string)
   156  	return &mail.Address{
   157  		Name:  publicName,
   158  		Email: email,
   159  	}, nil
   160  }
   161  
   162  func addressFromInstance(i *instance.Instance) (*mail.Address, error) {
   163  	doc, err := i.SettingsDocument()
   164  	if err != nil {
   165  		return nil, err
   166  	}
   167  	email, ok := doc.M["email"].(string)
   168  	if !ok {
   169  		return nil, fmt.Errorf("Domain %s has no email in its settings", i.Domain)
   170  	}
   171  	publicName, _ := doc.M["public_name"].(string)
   172  	return &mail.Address{
   173  		Name:  publicName,
   174  		Email: email,
   175  	}, nil
   176  }
   177  
   178  func doSendMail(ctx *job.TaskContext, opts *mail.Options, domain string) error {
   179  	if opts.TemplateName == "" && opts.Subject == "" {
   180  		return errors.New("Missing mail subject")
   181  	}
   182  	if len(opts.To) == 0 {
   183  		return errors.New("Missing mail recipient")
   184  	}
   185  	if opts.From == nil {
   186  		return errors.New("Missing mail sender")
   187  	}
   188  	email := gomail.NewMessage()
   189  	dialerOptions := opts.Dialer
   190  	if dialerOptions == nil {
   191  		if opts.Mode == mail.ModeCampaign {
   192  			dialerOptions = config.GetConfig().CampaignMail
   193  		} else {
   194  			dialerOptions = config.GetConfig().Mail
   195  		}
   196  	}
   197  	if dialerOptions.Host == "-" {
   198  		return nil
   199  	}
   200  	var date time.Time
   201  	if opts.Date == nil {
   202  		date = time.Now()
   203  	} else {
   204  		date = *opts.Date
   205  	}
   206  	toAddresses := make([]string, len(opts.To))
   207  	for i, to := range opts.To {
   208  		// See https://tools.ietf.org/html/rfc5322#section-3.4
   209  		// We want to use an email address in the "display-name <addr-spec>"
   210  		// format. If it is the case, the address is taken as is. Else, gomail
   211  		// is used to format it.
   212  		to.Email = strings.TrimSpace(to.Email)
   213  		if strings.HasSuffix(to.Email, ">") {
   214  			toAddresses[i] = to.Email
   215  		} else {
   216  			toAddresses[i] = email.FormatAddress(to.Email, to.Name)
   217  		}
   218  	}
   219  
   220  	var parts []*mail.Part
   221  	var err error
   222  
   223  	if opts.TemplateName != "" {
   224  		// Defining the master layout which will wrap the content
   225  		layout := opts.Layout
   226  		if layout == "" {
   227  			layout = mail.DefaultLayout
   228  		}
   229  		opts.Subject, parts, err = RenderMail(ctx, opts.TemplateName, layout, opts.Locale, opts.RecipientName, opts.TemplateValues)
   230  		if err != nil {
   231  			return err
   232  		}
   233  	} else {
   234  		parts = opts.Parts
   235  	}
   236  
   237  	headers := map[string][]string{
   238  		"From":    {email.FormatAddress(opts.From.Email, opts.From.Name)},
   239  		"To":      toAddresses,
   240  		"Subject": {opts.Subject},
   241  		"X-Cozy":  {domain},
   242  	}
   243  	if opts.ReplyTo != nil {
   244  		headers["Reply-To"] = []string{
   245  			email.FormatAddress(opts.ReplyTo.Email, opts.ReplyTo.Name),
   246  		}
   247  	}
   248  	email.SetHeaders(headers)
   249  	email.SetDateHeader("Date", date)
   250  
   251  	for _, part := range parts {
   252  		if err = addPart(email, part); err != nil {
   253  			return err
   254  		}
   255  	}
   256  
   257  	for _, attachment := range opts.Attachments {
   258  		email.Attach(attachment.Filename, gomail.SetCopyFunc(func(w io.Writer) error {
   259  			_, err := w.Write(attachment.Content)
   260  			return err
   261  		}))
   262  	}
   263  
   264  	dialer := gomail.NewDialer(dialerOptions)
   265  	if deadline, ok := ctx.Deadline(); ok {
   266  		dialer.SetDeadline(deadline)
   267  	}
   268  	return dialer.DialAndSend(email)
   269  }
   270  
   271  func addPart(mail *gomail.Message, part *mail.Part) error {
   272  	contentType := part.Type
   273  	if contentType != "text/plain" && contentType != "text/html" {
   274  		return fmt.Errorf("Unknown body content-type %s", contentType)
   275  	}
   276  	mail.AddAlternative(contentType, part.Body)
   277  	return nil
   278  }
   279  
   280  func sendSupportMail(ctx *job.TaskContext, opts *mail.Options, domain string) error {
   281  	email := gomail.NewMessage()
   282  	dialerOptions := opts.Dialer
   283  	if dialerOptions == nil {
   284  		dialerOptions = config.GetConfig().Mail
   285  	}
   286  	if dialerOptions.Host == "-" {
   287  		return nil
   288  	}
   289  	var date time.Time
   290  	if opts.Date == nil {
   291  		date = time.Now()
   292  	} else {
   293  		date = *opts.Date
   294  	}
   295  	headers := map[string][]string{
   296  		"From":    {email.FormatAddress(opts.To[0].Email, opts.To[0].Name)},
   297  		"To":      {email.FormatAddress(opts.ReplyTo.Email, opts.ReplyTo.Name)},
   298  		"Subject": {opts.Subject},
   299  		"X-Cozy":  {domain},
   300  	}
   301  	if opts.ReplyTo != nil {
   302  		headers["Reply-To"] = []string{
   303  			email.FormatAddress(opts.ReplyTo.Email, opts.ReplyTo.Name),
   304  		}
   305  	}
   306  	email.SetHeaders(headers)
   307  	email.SetDateHeader("Date", date)
   308  
   309  	intro := fmt.Sprintf("Demande de support pour %s:\n\n", domain)
   310  	body, _ := opts.TemplateValues["Body"].(string)
   311  	email.AddAlternative("text/plain", intro+body+"\n")
   312  
   313  	dialer := gomail.NewDialer(dialerOptions)
   314  	if deadline, ok := ctx.Deadline(); ok {
   315  		dialer.SetDeadline(deadline)
   316  	}
   317  	return dialer.DialAndSend(email)
   318  }