Skip to content

Commit

Permalink
feat: Allow app services to send marketing emails (#4320)
Browse files Browse the repository at this point in the history
Preserve mail server used for transactional emails from spam detection
by allowing webapps to send marketing emails via a dedicated mail
server.
  • Loading branch information
taratatach committed Feb 13, 2024
2 parents 5402bae + ada97fa commit 2c11baa
Show file tree
Hide file tree
Showing 25 changed files with 674 additions and 179 deletions.
3 changes: 3 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ example), you can use the --appdir flag like this:
cfg.Mail.NativeTLS = false
cfg.Mail.DisableTLS = true
cfg.Mail.Port = 1025
cfg.CampaignMail.NativeTLS = false
cfg.CampaignMail.DisableTLS = true
cfg.CampaignMail.Port = 1025
}

processes, services, err := stack.Start()
Expand Down
39 changes: 39 additions & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,45 @@ mail:
username: {{.Env.COZY_BETA_MAIL_USERNAME}}
password: {{.Env.COZY_BETA_MAIL_PASSWORD}}

# campaign mail service parameters for sending campaign emails via SMTP
# If campaign_mail.host is empty, the default mail config will be used.
campaign_mail:
# SMTP server host
# Defaults to empty string
host: smtp.home
# SMTP server port
# Defaults to 25
port: 587
# SMTP server username
# Defaults to empty string
username: {{.Env.COZY_MAIL_USERNAME}}
# SMTP server password
# Defaults to empty string
password: {{.Env.COZY_MAIL_PASSWORD}}
# Use SSL connection (SMTPS)
# Means no STARTTLS
# Defaults to false
use_ssl: false
# Disable STARTTLS for SMTP server
# Means using plain unencrypted SMTP
# Defaults to true
disable_tls: false
# Skip the certificate validation (may be useful on localhost)
# Defaults to false
skip_certificate_validation: false
# Local Name
# The hostname sent to the SMTP server with the HELO command
# Defaults to empty string
local_name: cozy.domain.example
# It is also possible to override the campaign mail config per context.
contexts:
beta:
# If the host is set to "-", no mail will be sent on this context
host: smtp.cozy.beta
port: 587
username: {{.Env.COZY_BETA_MAIL_USERNAME}}
password: {{.Env.COZY_BETA_MAIL_PASSWORD}}

# location of the database for IP -> City lookups - flags: --geodb
# See https://dev.maxmind.com/geoip/geoip2/geolite2/
geodb: ""
Expand Down
60 changes: 60 additions & 0 deletions docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,66 @@ Content-Type: application/vnd.api+json
HTTP/1.1 204 No Content
```

### POST /jobs/campaign-emails

Send a non transactional (or campaign) email to the user via the dedicated
campaign mail server (configured via `campaign_mail` attributes in the config
file, or overwritten by context with `campaign_mail.contexts.<name>`
attributes).

Both the subject and at least one part are required.

#### Request

```http
POST /jobs/campaign-emails HTTP/1.1
Content-Type: application/vnd.api+json
```

```json
{
"data": {
"attributes": {
"arguments": {
"subject": "Checkout the new cool stuff!",
"parts": [
{
"body": "So many new features to check out!",
"type": "text/plain"
}
]
}
}
}
}
```

#### Response

```http
HTTP/1.1 204 No Content
```

#### Permissions

To use this endpoint, an application needs a permission on the type
`io.cozy.jobs` for the verb `POST` and the `sendmail` worker.
This can be defined like so:

```json
{
"permissions": {
"campaign-emails": {
"description": "Required to send campaign emails to the user",
"type": "io.cozy.jobs",
"verbs": ["POST"],
"selector": "worker",
"values": ["sendmail"]
}
}
}
```

### GET /jobs/queue/:worker-type

List the jobs in the queue.
Expand Down
5 changes: 5 additions & 0 deletions docs/workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ file at the root of this repository.
- `from` to send a mail from the user
- `support` to send both an email to the support and a confirmation to
the user
- `campaign` to send a non transactional email to the user via an SMTP
server using the following configurations, in order of priority:
1. `campaign_mail.contexts.<context name>` if defined
2. `campaign_mail` otherwise
3. `mail` as the final fallback
- `to`: list of object `{name, email}` representing the addresses of the
recipients. (should not be used in `noreply` mode)
- `subject`: string specifying the subject of the mail
Expand Down
2 changes: 1 addition & 1 deletion model/instance/lifecycle/magic_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func SendMagicLink(inst *instance.Instance, redirect string) error {
"redirect": []string{redirect},
})
publicName, _ := csettings.PublicName(inst)
return emailer.SendEmail(inst, &emailer.SendEmailCmd{
return emailer.SendEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "magic_link",
TemplateValues: map[string]interface{}{
"MagicLink": link,
Expand Down
4 changes: 2 additions & 2 deletions model/instance/lifecycle/passphrase.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func SendHint(inst *instance.Instance) error {
if err != nil {
return err
}
return emailer.SendEmail(inst, &emailer.SendEmailCmd{
return emailer.SendEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "passphrase_hint",
TemplateValues: map[string]interface{}{
"BaseURL": inst.PageURL("/", nil),
Expand Down Expand Up @@ -125,7 +125,7 @@ func RequestPassphraseReset(inst *instance.Instance, from string) error {
if err != nil {
return err
}
return emailer.SendEmail(inst, &emailer.SendEmailCmd{
return emailer.SendEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "passphrase_reset",
TemplateValues: map[string]interface{}{
"BaseURL": inst.PageURL("/", nil),
Expand Down
4 changes: 2 additions & 2 deletions model/instance/lifecycle/two_factor_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func SendTwoFactorPasscode(inst *instance.Instance) ([]byte, error) {
if err != nil {
return nil, err
}
err = emailer.SendEmail(inst, &emailer.SendEmailCmd{
err = emailer.SendEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "two_factor",
TemplateValues: map[string]interface{}{"TwoFactorPasscode": passcode},
})
Expand All @@ -29,7 +29,7 @@ func SendMailConfirmationCode(inst *instance.Instance) error {
if err != nil {
return err
}
return emailer.SendEmail(inst, &emailer.SendEmailCmd{
return emailer.SendEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "two_factor_mail_confirmation",
TemplateValues: map[string]interface{}{"TwoFactorActivationPasscode": passcode},
})
Expand Down
4 changes: 2 additions & 2 deletions model/session/login_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func sendLoginNotification(i *instance.Instance, l *LoginEntry) error {
"ActivateTwoFALink": activateTwoFALink,
}

return emailer.SendEmail(i, &emailer.SendEmailCmd{
return emailer.SendEmail(i, &emailer.TransactionalEmailCmd{
TemplateName: "new_connection",
TemplateValues: templateValues,
})
Expand All @@ -244,7 +244,7 @@ func SendNewRegistrationNotification(i *instance.Instance, clientRegistrationID
"RevokeLink": revokeLink.String(),
}

return emailer.SendEmail(i, &emailer.SendEmailCmd{
return emailer.SendEmail(i, &emailer.TransactionalEmailCmd{
TemplateName: "new_registration",
TemplateValues: templateValues,
})
Expand Down
4 changes: 2 additions & 2 deletions model/settings/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func (s *SettingsService) StartEmailUpdate(inst *instance.Instance, cmd *UpdateE
"token": []string{token},
})

err = s.emailer.SendPendingEmail(inst, &emailer.SendEmailCmd{
err = s.emailer.SendPendingEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "update_email",
TemplateValues: map[string]interface{}{
"PublicName": publicName,
Expand Down Expand Up @@ -164,7 +164,7 @@ func (s *SettingsService) ResendEmailUpdate(inst *instance.Instance) error {
"token": []string{token},
})

err = s.emailer.SendPendingEmail(inst, &emailer.SendEmailCmd{
err = s.emailer.SendPendingEmail(inst, &emailer.TransactionalEmailCmd{
TemplateName: "update_email",
TemplateValues: map[string]interface{}{
"PublicName": publicName,
Expand Down
6 changes: 3 additions & 3 deletions model/settings/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func Test_StartEmailUpdate_success(t *testing.T) {
},
}).Return(nil).Once()

emailerSvc.On("SendPendingEmail", &inst, &emailer.SendEmailCmd{
emailerSvc.On("SendPendingEmail", &inst, &emailer.TransactionalEmailCmd{
TemplateName: "update_email",
TemplateValues: map[string]interface{}{
"PublicName": "Jane Doe",
Expand Down Expand Up @@ -122,7 +122,7 @@ func Test_StartEmailUpdate_with_a_missing_public_name(t *testing.T) {
},
}).Return(nil).Once()

emailerSvc.On("SendPendingEmail", &inst, &emailer.SendEmailCmd{
emailerSvc.On("SendPendingEmail", &inst, &emailer.TransactionalEmailCmd{
TemplateName: "update_email",
TemplateValues: map[string]interface{}{
"PublicName": "foo", // Change here
Expand Down Expand Up @@ -310,7 +310,7 @@ func Test_ResendEmailUpdate_success(t *testing.T) {
tokenSvc.On("GenerateAndSave", &inst, token.EmailUpdate, "foo.mycozy.cloud", TokenExpiration).
Return("some-token", nil).Once()

emailerSvc.On("SendPendingEmail", &inst, &emailer.SendEmailCmd{
emailerSvc.On("SendPendingEmail", &inst, &emailer.TransactionalEmailCmd{
TemplateName: "update_email",
TemplateValues: map[string]interface{}{
"PublicName": "Jane Doe",
Expand Down
2 changes: 2 additions & 0 deletions model/stack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (g gopAgent) Shutdown(ctx context.Context) error {
}

type Services struct {
Emailer emailer.Emailer
Settings settings.Service
}

Expand Down Expand Up @@ -111,6 +112,7 @@ security features. Please do not use this binary as your production server.
clouderySvc := cloudery.Init(config.GetConfig().Clouderies)

services := Services{
Emailer: emailerSvc,
Settings: settings.Init(emailerSvc, instanceSvc, tokenSvc, clouderySvc),
}

Expand Down
102 changes: 69 additions & 33 deletions pkg/config/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,19 @@ type Config struct {
RemoteAssets map[string]string
DeprecatedApps DeprecatedAppsCfg

Avatars *avatar.Service
Fs Fs
Keyring keyring.Keyring
CouchDB CouchDB
Jobs Jobs
Konnectors Konnectors
Mail *gomail.DialerOptions
MailPerContext map[string]interface{}
Move Move
Notifications Notifications
Flagship Flagship
Avatars *avatar.Service
Fs Fs
Keyring keyring.Keyring
CouchDB CouchDB
Jobs Jobs
Konnectors Konnectors
Mail *gomail.DialerOptions
MailPerContext map[string]interface{}
CampaignMail *gomail.DialerOptions
CampaignMailPerContext map[string]interface{}
Move Move
Notifications Notifications
Flagship Flagship

Lock lock.Getter
Limiter *limits.RateLimiter
Expand Down Expand Up @@ -756,6 +758,47 @@ func UseViper(v *viper.Viper) error {
return fmt.Errorf("failed to setup the keyring: %w", err)
}

// Setup default SMTP server
mail := &gomail.DialerOptions{
Host: v.GetString("mail.host"),
Port: v.GetInt("mail.port"),
Username: v.GetString("mail.username"),
Password: v.GetString("mail.password"),
NativeTLS: v.GetBool("mail.use_ssl"),
DisableTLS: v.GetBool("mail.disable_tls"),
SkipCertificateValidation: v.GetBool("mail.skip_certificate_validation"),
LocalName: v.GetString("mail.local_name"),
}

// Setup campaign mail SMTP server
var campaignMail *gomail.DialerOptions
if host := v.GetString("campaign_mail.host"); host != "" {
viper.SetDefault("campaign_mail.port", 25)
viper.SetDefault("campaign_mail.disable_tls", true)

campaignMail = &gomail.DialerOptions{
Host: host,
Port: v.GetInt("campaign_mail.port"),
Username: v.GetString("campaign_mail.username"),
Password: v.GetString("campaign_mail.password"),
NativeTLS: v.GetBool("campaign_mail.use_ssl"),
DisableTLS: v.GetBool("campaign_mail.disable_tls"),
SkipCertificateValidation: v.GetBool("campaign_mail.skip_certificate_validation"),
LocalName: v.GetString("campaign_mail.local_name"),
}
} else {
campaignMail = &gomail.DialerOptions{
Host: mail.Host,
Port: mail.Port,
Username: mail.Username,
Password: mail.Password,
NativeTLS: mail.NativeTLS,
DisableTLS: mail.DisableTLS,
SkipCertificateValidation: mail.SkipCertificateValidation,
LocalName: mail.LocalName,
}
}

config = &Config{
Host: v.GetString("host"),
Port: v.GetInt("port"),
Expand Down Expand Up @@ -822,28 +865,21 @@ func UseViper(v *viper.Viper) error {
PlayIntegrityVerificationKeys: v.GetStringSlice("flagship.play_integrity_verification_keys"),
AppleAppIDs: v.GetStringSlice("flagship.apple_app_ids"),
},
Lock: lock.New(lockRedis),
SessionStorage: sessionsRedis,
DownloadStorage: downloadRedis,
Limiter: limits.NewRateLimiter(rateLimitingRedis),
OauthStateStorage: oauthStateRedis,
Realtime: realtimeRedis,
CacheStorage: cacheStorage,
Mail: &gomail.DialerOptions{
Host: v.GetString("mail.host"),
Port: v.GetInt("mail.port"),
Username: v.GetString("mail.username"),
Password: v.GetString("mail.password"),
NativeTLS: v.GetBool("mail.use_ssl"),
DisableTLS: v.GetBool("mail.disable_tls"),
SkipCertificateValidation: v.GetBool("mail.skip_certificate_validation"),
LocalName: v.GetString("mail.local_name"),
},
MailPerContext: v.GetStringMap("mail.contexts"),
Contexts: v.GetStringMap("contexts"),
Authentication: v.GetStringMap("authentication"),
Office: office,
Registries: regs,
Lock: lock.New(lockRedis),
SessionStorage: sessionsRedis,
DownloadStorage: downloadRedis,
Limiter: limits.NewRateLimiter(rateLimitingRedis),
OauthStateStorage: oauthStateRedis,
Realtime: realtimeRedis,
CacheStorage: cacheStorage,
Mail: mail,
MailPerContext: v.GetStringMap("mail.contexts"),
CampaignMail: campaignMail,
CampaignMailPerContext: v.GetStringMap("campaign_mail.contexts"),
Contexts: v.GetStringMap("contexts"),
Authentication: v.GetStringMap("authentication"),
Office: office,
Registries: regs,

CSPAllowList: cspAllowList,
CSPPerContext: cspPerContext,
Expand Down
Loading

0 comments on commit 2c11baa

Please sign in to comment.