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 }