diff --git a/cmd/serve.go b/cmd/serve.go index bd1e4317b4c..e791ec75462 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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() diff --git a/cozy.example.yaml b/cozy.example.yaml index 30d947f0ccb..8649aa74863 100644 --- a/cozy.example.yaml +++ b/cozy.example.yaml @@ -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: "" diff --git a/docs/jobs.md b/docs/jobs.md index 19af7d8a386..cf2ccf05b90 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -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.` +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. diff --git a/docs/workers.md b/docs/workers.md index 606a124fab4..aca6676b7b2 100644 --- a/docs/workers.md +++ b/docs/workers.md @@ -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.` 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 diff --git a/model/instance/lifecycle/magic_link.go b/model/instance/lifecycle/magic_link.go index fd8ad32675d..00b2e96554f 100644 --- a/model/instance/lifecycle/magic_link.go +++ b/model/instance/lifecycle/magic_link.go @@ -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, diff --git a/model/instance/lifecycle/passphrase.go b/model/instance/lifecycle/passphrase.go index 3b56181df34..f5743c5f892 100644 --- a/model/instance/lifecycle/passphrase.go +++ b/model/instance/lifecycle/passphrase.go @@ -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), @@ -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), diff --git a/model/instance/lifecycle/two_factor_auth.go b/model/instance/lifecycle/two_factor_auth.go index 0f4102d63e1..702eef713e9 100644 --- a/model/instance/lifecycle/two_factor_auth.go +++ b/model/instance/lifecycle/two_factor_auth.go @@ -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}, }) @@ -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}, }) diff --git a/model/session/login_history.go b/model/session/login_history.go index 4a9a0e3e0d7..56adb297e9e 100644 --- a/model/session/login_history.go +++ b/model/session/login_history.go @@ -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, }) @@ -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, }) diff --git a/model/settings/service.go b/model/settings/service.go index 2f6230d850d..ac22ca72143 100644 --- a/model/settings/service.go +++ b/model/settings/service.go @@ -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, @@ -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, diff --git a/model/settings/service_test.go b/model/settings/service_test.go index b4b6e3cd6e9..f1ae44ceb4e 100644 --- a/model/settings/service_test.go +++ b/model/settings/service_test.go @@ -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", @@ -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 @@ -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", diff --git a/model/stack/main.go b/model/stack/main.go index 876bc260f7b..733071184cd 100644 --- a/model/stack/main.go +++ b/model/stack/main.go @@ -50,6 +50,7 @@ func (g gopAgent) Shutdown(ctx context.Context) error { } type Services struct { + Emailer emailer.Emailer Settings settings.Service } @@ -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), } diff --git a/pkg/config/config/config.go b/pkg/config/config/config.go index ecfbccb6bf5..79dd3ca129c 100644 --- a/pkg/config/config/config.go +++ b/pkg/config/config/config.go @@ -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 @@ -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"), @@ -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, diff --git a/pkg/config/config/config_test.go b/pkg/config/config/config_test.go index f64d12b6371..a3265bb1b8e 100644 --- a/pkg/config/config/config_test.go +++ b/pkg/config/config/config_test.go @@ -101,6 +101,20 @@ func TestConfigUnmarshal(t *testing.T) { "my-context": map[string]interface{}{"host": "-"}, }, cfg.MailPerContext) + assert.EqualValues(t, &gomail.DialerOptions{ + Host: "smtp.localhost", + Port: 57, + Username: "campaign-username", + Password: "campaign-password", + NativeTLS: true, + DisableTLS: false, + SkipCertificateValidation: false, + LocalName: "smtp.localhost", + }, cfg.CampaignMail) + assert.EqualValues(t, map[string]interface{}{ + "my-context": map[string]interface{}{"host": "-"}, + }, cfg.CampaignMailPerContext) + // Contexts assert.EqualValues(t, map[string]interface{}{ "my-context": map[string]interface{}{ diff --git a/pkg/config/config/testdata/full_config.yaml b/pkg/config/config/testdata/full_config.yaml index 426b616418a..866dfa682c7 100644 --- a/pkg/config/config/testdata/full_config.yaml +++ b/pkg/config/config/testdata/full_config.yaml @@ -71,6 +71,19 @@ mail: skip_certificate_validation: true local_name: some.host +campaign_mail: + contexts: + my-context: + host: "-" + host: smtp.localhost + username: campaign-username + password: campaign-password + port: 57 + use_ssl: true + disable_tls: false + skip_certificate_validation: false + local_name: smtp.localhost + geodb: /geo/db/path move: diff --git a/pkg/emailer/errors.go b/pkg/emailer/errors.go new file mode 100644 index 00000000000..6ff49ab2a3f --- /dev/null +++ b/pkg/emailer/errors.go @@ -0,0 +1,13 @@ +package emailer + +import "errors" + +var ( + // ErrMissingContent is used when sending an email without parts + ErrMissingContent = errors.New("emailer: missing content") + // ErrMissingSubject is used when sending an email without subject + ErrMissingSubject = errors.New("emailer: missing subject") + // ErrMissingTemplate is used when sending an email without a template name + // or template values + ErrMissingTemplate = errors.New("emailer: missing template") +) diff --git a/pkg/emailer/init.go b/pkg/emailer/init.go index f8c70a8df99..a33c6d8d763 100644 --- a/pkg/emailer/init.go +++ b/pkg/emailer/init.go @@ -13,8 +13,9 @@ var service *EmailerService // - [EmailerService] sending email via an async job // - [Mock] with a mock implementation type Emailer interface { - SendEmail(inst *instance.Instance, cmd *SendEmailCmd) error - SendPendingEmail(inst *instance.Instance, cmd *SendEmailCmd) error + SendEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error + SendPendingEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error + SendCampaignEmail(inst *instance.Instance, cmd *CampaignEmailCmd) error } // Init the emailer package by setting up a service based on the @@ -28,6 +29,6 @@ func Init() *EmailerService { // SendEmail send a mail to the instance owner. // // Deprecated: use [EmailerService.SendEmail] instead. -func SendEmail(inst *instance.Instance, cmd *SendEmailCmd) error { +func SendEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error { return service.SendEmail(inst, cmd) } diff --git a/pkg/emailer/service.go b/pkg/emailer/service.go index f02f47e8191..ec004be953c 100644 --- a/pkg/emailer/service.go +++ b/pkg/emailer/service.go @@ -3,6 +3,7 @@ package emailer import ( "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/job" + "github.com/cozy/cozy-stack/pkg/mail" ) // EmailerService allows to send emails. @@ -17,14 +18,19 @@ func NewEmailerService(jobBroker job.Broker) *EmailerService { return &EmailerService{jobBroker} } -// SendEmailCmd contains the informations to send a mail for the instance owner. -type SendEmailCmd struct { +// TransactionalEmailCmd contains the information to send a transactional email +// to the instance owner. +type TransactionalEmailCmd struct { TemplateName string TemplateValues map[string]interface{} } // SendEmail sends a mail to the instance owner. -func (s *EmailerService) SendEmail(inst *instance.Instance, cmd *SendEmailCmd) error { +func (s *EmailerService) SendEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error { + if cmd.TemplateName == "" || cmd.TemplateValues == nil { + return ErrMissingTemplate + } + msg, err := job.NewMessage(map[string]interface{}{ "mode": "noreply", "template_name": cmd.TemplateName, @@ -45,7 +51,11 @@ func (s *EmailerService) SendEmail(inst *instance.Instance, cmd *SendEmailCmd) e // SendPendingEmail sends a mail to the instance owner on their new pending // email address. It is used to confirm that they can receive emails on the new // email address. -func (s *EmailerService) SendPendingEmail(inst *instance.Instance, cmd *SendEmailCmd) error { +func (s *EmailerService) SendPendingEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error { + if cmd.TemplateName == "" || cmd.TemplateValues == nil { + return ErrMissingTemplate + } + msg, err := job.NewMessage(map[string]interface{}{ "mode": "pending", "template_name": cmd.TemplateName, @@ -62,3 +72,37 @@ func (s *EmailerService) SendPendingEmail(inst *instance.Instance, cmd *SendEmai return err } + +// CampaignEmailCmd contains the information required to send a campaign email +// to the instance owner. +type CampaignEmailCmd struct { + Parts []mail.Part + Subject string +} + +// SendCampaignEmail sends a campaign email to the instance owner with the +// given cmd content via the dedicated campaign mail server. +func (s *EmailerService) SendCampaignEmail(inst *instance.Instance, cmd *CampaignEmailCmd) error { + if cmd.Subject == "" { + return ErrMissingSubject + } + if cmd.Parts == nil { + return ErrMissingContent + } + + msg, err := job.NewMessage(map[string]interface{}{ + "mode": mail.ModeCampaign, + "subject": cmd.Subject, + "parts": cmd.Parts, + }) + if err != nil { + return err + } + + _, err = s.jobBroker.PushJob(inst, &job.JobRequest{ + WorkerType: "sendmail", + Message: msg, + }) + + return err +} diff --git a/pkg/emailer/service_mock.go b/pkg/emailer/service_mock.go index 21fcba9163b..266646d2659 100644 --- a/pkg/emailer/service_mock.go +++ b/pkg/emailer/service_mock.go @@ -22,11 +22,16 @@ func NewMock(t *testing.T) *Mock { } // SendEmail mock method. -func (m *Mock) SendEmail(inst *instance.Instance, cmd *SendEmailCmd) error { +func (m *Mock) SendEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error { return m.Called(inst, cmd).Error(0) } // SendPendingEmail mock method. -func (m *Mock) SendPendingEmail(inst *instance.Instance, cmd *SendEmailCmd) error { +func (m *Mock) SendPendingEmail(inst *instance.Instance, cmd *TransactionalEmailCmd) error { + return m.Called(inst, cmd).Error(0) +} + +// SendCampaignEmail mock method +func (m *Mock) SendCampaignEmail(inst *instance.Instance, cmd *CampaignEmailCmd) error { return m.Called(inst, cmd).Error(0) } diff --git a/pkg/emailer/service_test.go b/pkg/emailer/service_test.go index 9f17446b129..00ce2d182f5 100644 --- a/pkg/emailer/service_test.go +++ b/pkg/emailer/service_test.go @@ -6,6 +6,7 @@ import ( "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/job" + "github.com/cozy/cozy-stack/pkg/mail" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -35,7 +36,7 @@ func Test_Emailer_success(t *testing.T) { return true })).Return(nil, nil).Once() - err := emailer.SendEmail(&inst, &SendEmailCmd{ + err := emailer.SendEmail(&inst, &TransactionalEmailCmd{ TemplateName: "some-template.html", TemplateValues: map[string]interface{}{ "foo": "bar", @@ -56,7 +57,7 @@ func Test_Email_job_push_error(t *testing.T) { brokerMock.On("PushJob", &inst, mock.Anything). Return(nil, fmt.Errorf("some-error")).Once() - err := emailer.SendEmail(&inst, &SendEmailCmd{ + err := emailer.SendEmail(&inst, &TransactionalEmailCmd{ TemplateName: "some-template.html", TemplateValues: map[string]interface{}{ "foo": "bar", @@ -66,3 +67,52 @@ func Test_Email_job_push_error(t *testing.T) { assert.EqualError(t, err, "some-error") } + +func TestSendCampaignEmail(t *testing.T) { + brokerMock := job.NewBrokerMock(t) + + emailer := NewEmailerService(brokerMock) + + inst := instance.Instance{} + + t.Run("WithoutSubject", func(t *testing.T) { + err := emailer.SendCampaignEmail(&inst, &CampaignEmailCmd{}) + assert.ErrorIs(t, err, ErrMissingSubject) + }) + + t.Run("WithoutParts", func(t *testing.T) { + err := emailer.SendCampaignEmail(&inst, &CampaignEmailCmd{ + Subject: "Some subject", + }) + assert.ErrorIs(t, err, ErrMissingContent) + }) + + t.Run("WithCompleteCmd", func(t *testing.T) { + brokerMock.On("PushJob", &inst, mock.MatchedBy(func(req *job.JobRequest) bool { + assert.Equal(t, "sendmail", req.WorkerType) + assert.JSONEq(t, + `{ + "mode": "campaign", + "subject": "Some subject", + "parts": [{ + "body": "Hey !!!", + "type": "text/plain" + }] + }`, + string(req.Message), + ) + return true + })).Return(nil, nil).Once() + + err := emailer.SendCampaignEmail(&inst, &CampaignEmailCmd{ + Subject: "Some subject", + Parts: []mail.Part{ + { + Body: "Hey !!!", + Type: "text/plain", + }, + }, + }) + assert.NoError(t, err) + }) +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index b397a133aef..b756c496ee0 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -19,6 +19,8 @@ const ( // ModeSupport is used to send both a request to the support and a // confirmation to the user. ModeSupport = "support" + // ModeCampaign is used to send a non transactional email to the user + ModeCampaign = "campaign" // DefaultLayout defines the default MJML layout to use DefaultLayout = "layout" diff --git a/web/jobs/jobs.go b/web/jobs/jobs.go index 880ca4cbd41..c0e88ee4549 100644 --- a/web/jobs/jobs.go +++ b/web/jobs/jobs.go @@ -19,6 +19,7 @@ import ( "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/emailer" "github.com/cozy/cozy-stack/pkg/jsonapi" "github.com/cozy/cozy-stack/pkg/limits" "github.com/cozy/cozy-stack/pkg/mail" @@ -60,6 +61,13 @@ type ( apiSupport struct { Arguments map[string]string `json:"arguments"` } + apiCampaign struct { + Arguments apiCampaignArgs `json:"arguments"` + } + apiCampaignArgs struct { + Subject string `json:"subject"` + Parts []mail.Part `json:"parts"` + } apiQueue struct { workerType string } @@ -153,7 +161,17 @@ func (t apiTriggerState) MarshalJSON() ([]byte, error) { const bearerAuthScheme = "Bearer " -func getQueue(c echo.Context) error { +// HTTPHandler handle all the `/jobs` routes. +type HTTPHandler struct { + emailer emailer.Emailer +} + +// NewHTTPHandler instantiates a new [HTTPHandler]. +func NewHTTPHandler(emailer emailer.Emailer) *HTTPHandler { + return &HTTPHandler{emailer} +} + +func (h *HTTPHandler) getQueue(c echo.Context) error { instance := middlewares.GetInstance(c) workerType := c.Param("worker-type") @@ -175,7 +193,7 @@ func getQueue(c echo.Context) error { return jsonapi.DataList(c, http.StatusOK, objs, nil) } -func pushJob(c echo.Context) error { +func (h *HTTPHandler) pushJob(c echo.Context) error { instance := middlewares.GetInstance(c) req := apiJobRequest{} @@ -223,7 +241,7 @@ func pushJob(c echo.Context) error { return jsonapi.Data(c, http.StatusAccepted, apiJob{j}, nil) } -func contactSupport(c echo.Context) error { +func (h *HTTPHandler) contactSupport(c echo.Context) error { inst := middlewares.GetInstance(c) req := apiSupport{} @@ -259,7 +277,30 @@ func contactSupport(c echo.Context) error { return c.NoContent(http.StatusNoContent) } -func newTrigger(c echo.Context) error { +func (h *HTTPHandler) sendCampaignEmail(c echo.Context) error { + inst := middlewares.GetInstance(c) + + if err := middlewares.Allow(c, permission.POST, &job.JobRequest{WorkerType: "sendmail"}); err != nil { + return err + } + + req := apiCampaign{} + if _, err := jsonapi.Bind(c.Request().Body, &req); err != nil { + return wrapJobsError(err) + } + + err := h.emailer.SendCampaignEmail(inst, &emailer.CampaignEmailCmd{ + Subject: req.Arguments.Subject, + Parts: req.Arguments.Parts, + }) + if err != nil { + return wrapJobsError(err) + } + + return c.NoContent(http.StatusNoContent) +} + +func (h *HTTPHandler) newTrigger(c echo.Context) error { instance := middlewares.GetInstance(c) sched := job.System() req := apiTriggerRequest{} @@ -318,7 +359,7 @@ func newTrigger(c echo.Context) error { return jsonapi.Data(c, http.StatusCreated, apiTrigger{t.Infos(), instance}, nil) } -func getTrigger(c echo.Context) error { +func (h *HTTPHandler) getTrigger(c echo.Context) error { instance := middlewares.GetInstance(c) sched := job.System() t, err := sched.GetTrigger(instance, c.Param("trigger-id")) @@ -338,7 +379,7 @@ func getTrigger(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, apiTrigger{infos, instance}, nil) } -func getTriggerState(c echo.Context) error { +func (h *HTTPHandler) getTriggerState(c echo.Context) error { instance := middlewares.GetInstance(c) sched := job.System() t, err := sched.GetTrigger(instance, c.Param("trigger-id")) @@ -359,7 +400,7 @@ func getTriggerState(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, apiTriggerState{t: infos, s: state}, nil) } -func getTriggerJobs(c echo.Context) error { +func (h *HTTPHandler) getTriggerJobs(c echo.Context) error { instance := middlewares.GetInstance(c) var err error @@ -394,7 +435,7 @@ func getTriggerJobs(c echo.Context) error { return jsonapi.DataList(c, http.StatusOK, objs, nil) } -func patchTrigger(c echo.Context) error { +func (h *HTTPHandler) patchTrigger(c echo.Context) error { inst := middlewares.GetInstance(c) sched := job.System() t, err := sched.GetTrigger(inst, c.Param("trigger-id")) @@ -431,7 +472,7 @@ func patchTrigger(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, apiTrigger{infos, inst}, nil) } -func launchTrigger(c echo.Context) error { +func (h *HTTPHandler) launchTrigger(c echo.Context) error { instance := middlewares.GetInstance(c) t, err := job.System().GetTrigger(instance, c.Param("trigger-id")) if err != nil { @@ -454,7 +495,7 @@ func launchTrigger(c echo.Context) error { return jsonapi.Data(c, http.StatusCreated, apiJob{j}, nil) } -func deleteTrigger(c echo.Context) error { +func (h *HTTPHandler) deleteTrigger(c echo.Context) error { instance := middlewares.GetInstance(c) sched := job.System() t, err := sched.GetTrigger(instance, c.Param("trigger-id")) @@ -473,7 +514,7 @@ func deleteTrigger(c echo.Context) error { return c.NoContent(http.StatusNoContent) } -func fireBIWebhook(c echo.Context) error { +func (h *HTTPHandler) fireBIWebhook(c echo.Context) error { inst := middlewares.GetInstance(c) err := config.GetRateLimiter().CheckRateLimit(inst, limits.WebhookTriggerType) if limits.IsLimitReachedOrExceeded(err) { @@ -517,7 +558,7 @@ func fireBIWebhook(c echo.Context) error { return c.NoContent(http.StatusNoContent) } -func fireWebhook(c echo.Context) error { +func (h *HTTPHandler) fireWebhook(c echo.Context) error { inst := middlewares.GetInstance(c) err := config.GetRateLimiter().CheckRateLimit(inst, limits.WebhookTriggerType) if limits.IsLimitReachedOrExceeded(err) { @@ -546,7 +587,7 @@ func fireWebhook(c echo.Context) error { return c.NoContent(http.StatusNoContent) } -func getAllTriggers(c echo.Context) error { +func (h *HTTPHandler) getAllTriggers(c echo.Context) error { instance := middlewares.GetInstance(c) var workerTypes, triggerTypes []string @@ -612,7 +653,7 @@ func hasType(infos *job.TriggerInfos, triggerTypes []string) bool { return false } -func getJob(c echo.Context) error { +func (h *HTTPHandler) getJob(c echo.Context) error { instance := middlewares.GetInstance(c) j, err := job.Get(instance, c.Param("job-id")) if err != nil { @@ -624,7 +665,7 @@ func getJob(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, apiJob{j}, nil) } -func patchJob(c echo.Context) error { +func (h *HTTPHandler) patchJob(c echo.Context) error { inst := middlewares.GetInstance(c) j, err := job.Get(inst, c.Param("job-id")) if err != nil { @@ -674,7 +715,7 @@ func patchJob(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, apiJob{j}, nil) } -func cleanJobs(c echo.Context) error { +func (h *HTTPHandler) cleanJobs(c echo.Context) error { instance := middlewares.GetInstance(c) if err := middlewares.AllowWholeType(c, permission.POST, consts.Jobs); err != nil { return err @@ -710,7 +751,7 @@ func cleanJobs(c echo.Context) error { return c.JSON(200, map[string]int{"deleted": len(ups)}) } -func purgeJobs(c echo.Context) error { +func (h *HTTPHandler) purgeJobs(c echo.Context) error { instance := middlewares.GetInstance(c) if err := middlewares.AllowWholeType(c, permission.DELETE, consts.Jobs); err != nil { return err @@ -807,28 +848,29 @@ func purgeJobs(c echo.Context) error { return c.JSON(http.StatusOK, map[string]int{"deleted": len(jobsToDelete)}) } -// Routes sets the routing for the jobs service -func Routes(router *echo.Group) { - router.GET("/queue/:worker-type", getQueue) - router.POST("/queue/:worker-type", pushJob) - router.POST("/support", contactSupport) - - router.POST("/triggers", newTrigger) - router.GET("/triggers", getAllTriggers) - router.GET("/triggers/:trigger-id", getTrigger) - router.GET("/triggers/:trigger-id/state", getTriggerState) - router.GET("/triggers/:trigger-id/jobs", getTriggerJobs) - router.PATCH("/triggers/:trigger-id", patchTrigger) - router.POST("/triggers/:trigger-id/launch", launchTrigger) - router.DELETE("/triggers/:trigger-id", deleteTrigger) - - router.POST("/webhooks/bi", fireBIWebhook) - router.POST("/webhooks/:trigger-id", fireWebhook) - - router.POST("/clean", cleanJobs) - router.DELETE("/purge", purgeJobs) - router.GET("/:job-id", getJob) - router.PATCH("/:job-id", patchJob) +// Register all the `/jobs` routes to the given router +func (h *HTTPHandler) Register(router *echo.Group) { + router.GET("/queue/:worker-type", h.getQueue) + router.POST("/queue/:worker-type", h.pushJob) + router.POST("/support", h.contactSupport) + router.POST("/campaign-emails", h.sendCampaignEmail) + + router.POST("/triggers", h.newTrigger) + router.GET("/triggers", h.getAllTriggers) + router.GET("/triggers/:trigger-id", h.getTrigger) + router.GET("/triggers/:trigger-id/state", h.getTriggerState) + router.GET("/triggers/:trigger-id/jobs", h.getTriggerJobs) + router.PATCH("/triggers/:trigger-id", h.patchTrigger) + router.POST("/triggers/:trigger-id/launch", h.launchTrigger) + router.DELETE("/triggers/:trigger-id", h.deleteTrigger) + + router.POST("/webhooks/bi", h.fireBIWebhook) + router.POST("/webhooks/:trigger-id", h.fireWebhook) + + router.POST("/clean", h.cleanJobs) + router.DELETE("/purge", h.purgeJobs) + router.GET("/:job-id", h.getJob) + router.PATCH("/:job-id", h.patchJob) } func wrapJobsError(err error) error { @@ -840,7 +882,9 @@ func wrapJobsError(err error) error { case job.ErrUnknownTrigger, job.ErrNotCronTrigger: return jsonapi.InvalidAttribute("Type", err) - case limits.ErrRateLimitReached, + case emailer.ErrMissingSubject, + emailer.ErrMissingContent, + limits.ErrRateLimitReached, limits.ErrRateLimitExceeded: return jsonapi.BadRequest(err) } diff --git a/web/jobs/jobs_test.go b/web/jobs/jobs_test.go index f6c87e87547..5d66f34e969 100644 --- a/web/jobs/jobs_test.go +++ b/web/jobs/jobs_test.go @@ -1,22 +1,56 @@ package jobs import ( + "net/http/httptest" "strings" "testing" "time" + "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/job" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/emailer" + "github.com/cozy/cozy-stack/pkg/mail" "github.com/cozy/cozy-stack/tests/testutils" "github.com/cozy/cozy-stack/web/errors" "github.com/cozy/cozy-stack/web/middlewares" "github.com/gavv/httpexpect/v2" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +func setupRouter(t *testing.T, inst *instance.Instance, emailerSvc emailer.Emailer) *httptest.Server { + t.Helper() + + handler := echo.New() + handler.HTTPErrorHandler = errors.ErrorHandler + group := handler.Group("/jobs", func(next echo.HandlerFunc) echo.HandlerFunc { + return func(context echo.Context) error { + context.Set("instance", inst) + + tok := middlewares.GetRequestToken(context) + // Forcing the token parsing to have the "claims" parameter in the + // context (in production, it is done via + // middlewares.CheckInstanceBlocked) + _, err := middlewares.ParseJWT(context, inst, tok) + if err != nil { + return err + } + + return next(context) + } + }) + + NewHTTPHandler(emailerSvc).Register(group) + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + + return ts +} + func TestJobs(t *testing.T) { if testing.Short() { t.Skip("an instance is required for this test: test skipped due to the use of --short flag") @@ -50,22 +84,8 @@ func TestJobs(t *testing.T) { token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope, "", time.Now()) - ts := setup.GetTestServer("/jobs", Routes, func(r *echo.Echo) *echo.Echo { - r.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - tok := middlewares.GetRequestToken(c) - // Forcing the token parsing to have the "claims" parameter in the - // context (in production, it is done via - // middlewares.CheckInstanceBlocked) - _, err := middlewares.ParseJWT(c, testInstance, tok) - if err != nil { - return err - } - return next(c) - } - }) - return r - }) + emailerSvc := emailer.NewMock(t) + ts := setupRouter(t, testInstance, emailerSvc) ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler t.Cleanup(ts.Close) @@ -77,7 +97,7 @@ func TestJobs(t *testing.T) { Expect().Status(200). JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object().Value("data").Array(). - Length().Equal(0) + Length().IsEqual(0) }) t.Run("CreateJob", func(t *testing.T) { @@ -96,7 +116,7 @@ func TestJobs(t *testing.T) { Object() attrs := obj.Path("$.data.attributes").Object() - attrs.ValueEqual("worker", "print") + attrs.HasValue("worker", "print") attrs.NotContainsKey("manual_execution") }) @@ -144,8 +164,8 @@ func TestJobs(t *testing.T) { Object() attrs := obj.Path("$.data.attributes").Object() - attrs.ValueEqual("worker", "print") - attrs.ValueEqual("manual_execution", true) + attrs.HasValue("worker", "print") + attrs.HasValue("manual_execution", true) }) t.Run("CreateJobForReservedWorker", func(t *testing.T) { @@ -198,11 +218,11 @@ func TestJobs(t *testing.T) { data := obj.Value("data").Object() triggerID = data.Value("id").String().NotEmpty().Raw() - data.ValueEqual("type", consts.Triggers) + data.HasValue("type", consts.Triggers) attrs := data.Value("attributes").Object() - attrs.ValueEqual("arguments", at) - attrs.ValueEqual("worker", "print") + attrs.HasValue("arguments", at) + attrs.HasValue("worker", "print") }) t.Run("AddFailure", func(t *testing.T) { @@ -274,12 +294,12 @@ func TestJobs(t *testing.T) { data := obj.Value("data").Object() triggerID = data.Value("id").String().NotEmpty().Raw() - data.ValueEqual("type", consts.Triggers) + data.HasValue("type", consts.Triggers) attrs := data.Value("attributes").Object() - attrs.ValueEqual("type", "@in") - attrs.ValueEqual("arguments", "1s") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@in") + attrs.HasValue("arguments", "1s") + attrs.HasValue("worker", "print") }) t.Run("AddFailure", func(t *testing.T) { @@ -351,12 +371,12 @@ func TestJobs(t *testing.T) { data := obj.Value("data").Object() triggerID = data.Value("id").String().NotEmpty().Raw() - data.ValueEqual("type", consts.Triggers) + data.HasValue("type", consts.Triggers) attrs := data.Value("attributes").Object() - attrs.ValueEqual("type", "@cron") - attrs.ValueEqual("arguments", "0 0 0 * * 0") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@cron") + attrs.HasValue("arguments", "0 0 0 * * 0") + attrs.HasValue("worker", "print") }) t.Run("PatchArgumentsSuccess", func(t *testing.T) { @@ -404,12 +424,12 @@ func TestJobs(t *testing.T) { data := obj.Value("data").Object() triggerID = data.Value("id").String().NotEmpty().Raw() - data.ValueEqual("type", consts.Triggers) + data.HasValue("type", consts.Triggers) attrs := data.Value("attributes").Object() - attrs.ValueEqual("type", "@cron") - attrs.ValueEqual("arguments", "0 0 0 * * 1") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@cron") + attrs.HasValue("arguments", "0 0 0 * * 1") + attrs.HasValue("worker", "print") }) t.Run("DeleteSuccess", func(t *testing.T) { @@ -448,20 +468,20 @@ func TestJobs(t *testing.T) { data := obj.Value("data").Object() triggerID = data.Value("id").String().NotEmpty().Raw() - data.ValueEqual("type", consts.Triggers) - data.Path("$.links.webhook").Equal("https://" + testInstance.Domain + "/jobs/webhooks/" + triggerID) + data.HasValue("type", consts.Triggers) + data.Path("$.links.webhook").IsEqual("https://" + testInstance.Domain + "/jobs/webhooks/" + triggerID) attrs := data.Value("attributes").Object() - attrs.ValueEqual("type", "@webhook") - attrs.ValueEqual("arguments", at) - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@webhook") + attrs.HasValue("arguments", at) + attrs.HasValue("worker", "print") metas := attrs.Value("cozyMetadata").Object() - metas.ValueEqual("doctypeVersion", "1") - metas.ValueEqual("metadataVersion", 1) - metas.ValueEqual("createdByApp", "CLI") - metas.Value("createdAt").String().DateTime(time.RFC3339) - metas.Value("updatedAt").String().DateTime(time.RFC3339) + metas.HasValue("doctypeVersion", "1") + metas.HasValue("metadataVersion", 1) + metas.HasValue("createdByApp", "CLI") + metas.Value("createdAt").String().AsDateTime(time.RFC3339) + metas.Value("updatedAt").String().AsDateTime(time.RFC3339) }) t.Run("GetSuccess", func(t *testing.T) { @@ -492,7 +512,7 @@ func TestJobs(t *testing.T) { Expect().Status(200). JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object(). - Value("data").Array().Empty() + Value("data").Array().IsEmpty() }) t.Run("CreateAJob", func(t *testing.T) { @@ -525,13 +545,13 @@ func TestJobs(t *testing.T) { JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object() - obj.Value("data").Array().Length().Equal(1) - elem := obj.Value("data").Array().First().Object() - elem.ValueEqual("type", consts.Triggers) + obj.Value("data").Array().Length().IsEqual(1) + elem := obj.Value("data").Array().Value(0).Object() + elem.HasValue("type", consts.Triggers) attrs := elem.Value("attributes").Object() - attrs.ValueEqual("type", "@in") - attrs.ValueEqual("arguments", "10s") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@in") + attrs.HasValue("arguments", "10s") + attrs.HasValue("worker", "print") }) t.Run("WithWorkerQueryAndResult", func(t *testing.T) { @@ -544,13 +564,13 @@ func TestJobs(t *testing.T) { JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object() - obj.Value("data").Array().Length().Equal(1) - elem := obj.Value("data").Array().First().Object() - elem.ValueEqual("type", consts.Triggers) + obj.Value("data").Array().Length().IsEqual(1) + elem := obj.Value("data").Array().Value(0).Object() + elem.HasValue("type", consts.Triggers) attrs := elem.Value("attributes").Object() - attrs.ValueEqual("type", "@in") - attrs.ValueEqual("arguments", "10s") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@in") + attrs.HasValue("arguments", "10s") + attrs.HasValue("worker", "print") }) t.Run("WithWorkerQueryAndNoResults", func(t *testing.T) { @@ -562,7 +582,7 @@ func TestJobs(t *testing.T) { Expect().Status(200). JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object().Value("data"). - Array().Empty() + Array().IsEmpty() }) t.Run("WithTypeQuery", func(t *testing.T) { @@ -575,13 +595,13 @@ func TestJobs(t *testing.T) { JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object() - obj.Value("data").Array().Length().Equal(1) - elem := obj.Value("data").Array().First().Object() - elem.ValueEqual("type", consts.Triggers) + obj.Value("data").Array().Length().IsEqual(1) + elem := obj.Value("data").Array().Value(0).Object() + elem.HasValue("type", consts.Triggers) attrs := elem.Value("attributes").Object() - attrs.ValueEqual("type", "@in") - attrs.ValueEqual("arguments", "10s") - attrs.ValueEqual("worker", "print") + attrs.HasValue("type", "@in") + attrs.HasValue("arguments", "10s") + attrs.HasValue("worker", "print") }) }) @@ -613,8 +633,8 @@ func TestJobs(t *testing.T) { triggerID = obj.Path("$.data.id").String().NotEmpty().Raw() attrs := obj.Path("$.data.attributes").Object() - attrs.ValueEqual("type", "@client") - attrs.ValueEqual("worker", "client") + attrs.HasValue("type", "@client") + attrs.HasValue("worker", "client") }) t.Run("LaunchAClientJob", func(t *testing.T) { @@ -628,12 +648,12 @@ func TestJobs(t *testing.T) { jobID = obj.Path("$.data.id").String().NotEmpty().Raw() - obj.Path("$.data.type").Equal(consts.Jobs) + obj.Path("$.data.type").IsEqual(consts.Jobs) attrs := obj.Path("$.data.attributes").Object() - attrs.ValueEqual("worker", "client") - attrs.ValueEqual("state", job.Running) - attrs.Value("queued_at").String().DateTime(time.RFC3339) - attrs.Value("started_at").String().DateTime(time.RFC3339) + attrs.HasValue("worker", "client") + attrs.HasValue("state", job.Running) + attrs.Value("queued_at").String().AsDateTime(time.RFC3339) + attrs.Value("started_at").String().AsDateTime(time.RFC3339) }) t.Run("PatchAClientJob", func(t *testing.T) { @@ -654,14 +674,108 @@ func TestJobs(t *testing.T) { JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). Object() - obj.Path("$.data.type").Equal(consts.Jobs) + obj.Path("$.data.type").IsEqual(consts.Jobs) attrs := obj.Path("$.data.attributes").Object() - attrs.ValueEqual("worker", "client") - attrs.ValueEqual("state", job.Errored) - attrs.ValueEqual("error", "LOGIN_FAILED") - attrs.Value("queued_at").String().DateTime(time.RFC3339) - attrs.Value("started_at").String().DateTime(time.RFC3339) - attrs.Value("finished_at").String().DateTime(time.RFC3339) + attrs.HasValue("worker", "client") + attrs.HasValue("state", job.Errored) + attrs.HasValue("error", "LOGIN_FAILED") + attrs.Value("queued_at").String().AsDateTime(time.RFC3339) + attrs.Value("started_at").String().AsDateTime(time.RFC3339) + attrs.Value("finished_at").String().AsDateTime(time.RFC3339) + }) + }) + + t.Run("SendCampaignEmail", func(t *testing.T) { + e := testutils.CreateTestClient(t, ts.URL) + + t.Run("WithoutPermissions", func(t *testing.T) { + e.POST("/jobs/campaign-emails"). + WithHeader("Authorization", "Bearer "+token). + WithHeader("Content-Type", "application/json"). + WithBytes([]byte(`{ + "data": { + "attributes": { + "arguments": { + "subject": "Some subject", + "parts": [ + { "body": "Some content", "type": "text/plain" } + ] + } + } + } + }`)).Expect().Status(403) + + emailerSvc.AssertNumberOfCalls(t, "SendCampaignEmail", 0) + }) + + t.Run("WithProperArguments", func(t *testing.T) { + emailerSvc. + On("SendCampaignEmail", testInstance, mock.Anything). + Return(nil). + Once() + + scope := strings.Join([]string{ + consts.Jobs + ":ALL:sendmail:worker", + }, " ") + token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope, + "", time.Now()) + + e.POST("/jobs/campaign-emails"). + WithHeader("Authorization", "Bearer "+token). + WithHeader("Content-Type", "application/json"). + WithBytes([]byte(`{ + "data": { + "attributes": { + "arguments": { + "subject": "Some subject", + "parts": [ + { "body": "Some content", "type": "text/plain" } + ] + } + } + } + }`)).Expect().Status(204) + + emailerSvc.AssertCalled(t, "SendCampaignEmail", testInstance, &emailer.CampaignEmailCmd{ + Subject: "Some subject", + Parts: []mail.Part{ + {Body: "Some content", Type: "text/plain"}, + }, + }) + }) + + t.Run("WithMissingSubject", func(t *testing.T) { + emailerSvc. + On("SendCampaignEmail", testInstance, mock.Anything). + Return(emailer.ErrMissingSubject). + Once() + + scope := strings.Join([]string{ + consts.Jobs + ":ALL:sendmail:worker", + }, " ") + token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope, + "", time.Now()) + + e.POST("/jobs/campaign-emails"). + WithHeader("Authorization", "Bearer "+token). + WithHeader("Content-Type", "application/json"). + WithBytes([]byte(`{ + "data": { + "attributes": { + "arguments": { + "parts": [ + { "body": "Some content", "type": "text/plain" } + ] + } + } + } + }`)).Expect().Status(400) + + emailerSvc.AssertCalled(t, "SendCampaignEmail", testInstance, &emailer.CampaignEmailCmd{ + Parts: []mail.Part{ + {Body: "Some content", Type: "text/plain"}, + }, + }) }) }) } diff --git a/web/routing.go b/web/routing.go index 4d7497242b6..a5bd3832441 100644 --- a/web/routing.go +++ b/web/routing.go @@ -224,7 +224,7 @@ func SetupRoutes(router *echo.Echo, services *stack.Services) error { files.Routes(router.Group("/files", mws...)) contacts.Routes(router.Group("/contacts", mws...)) intents.Routes(router.Group("/intents", mws...)) - jobs.Routes(router.Group("/jobs", mws...)) + jobs.NewHTTPHandler(services.Emailer).Register(router.Group("/jobs", mws...)) notifications.Routes(router.Group("/notifications", mws...)) move.Routes(router.Group("/move", mws...)) permissions.Routes(router.Group("/permissions", mws...)) diff --git a/worker/mails/mail.go b/worker/mails/mail.go index 220a1f31e9f..ad06cb87b0d 100644 --- a/worker/mails/mail.go +++ b/worker/mails/mail.go @@ -36,6 +36,7 @@ func SendMail(ctx *job.TaskContext) error { if err != nil { return err } + from := config.GetConfig().NoReplyAddr name := config.GetConfig().NoReplyName replyTo := config.GetConfig().ReplyTo @@ -53,8 +54,15 @@ func SendMail(ctx *job.TaskContext) error { replyTo = reply } } + + var cfgPerContext map[string]interface{} + if opts.Mode == mail.ModeCampaign { + cfgPerContext = config.GetConfig().CampaignMailPerContext + } else { + cfgPerContext = config.GetConfig().MailPerContext + } + ctxName := ctx.Instance.ContextName - cfgPerContext := config.GetConfig().MailPerContext if ctxConfig, ok := cfgPerContext[ctxName].(map[string]interface{}); ok { if host, ok := ctxConfig["host"].(string); ok && host != "" { port, _ := ctxConfig["port"].(int) @@ -64,6 +72,7 @@ func SendMail(ctx *job.TaskContext) error { disableTLS, _ := ctxConfig["disable_tls"].(bool) skipCertValid, _ := ctxConfig["skip_certificate_validation"].(bool) LocalName, _ := ctxConfig["local_name"].(string) + opts.Dialer = &gomail.DialerOptions{ Host: host, Port: port, @@ -76,8 +85,9 @@ func SendMail(ctx *job.TaskContext) error { } } } + switch opts.Mode { - case mail.ModeFromStack: + case mail.ModeFromStack, mail.ModeCampaign: toAddr, err := addressFromInstance(ctx.Instance) if err != nil { return err @@ -178,7 +188,11 @@ func doSendMail(ctx *job.TaskContext, opts *mail.Options, domain string) error { email := gomail.NewMessage() dialerOptions := opts.Dialer if dialerOptions == nil { - dialerOptions = config.GetConfig().Mail + if opts.Mode == mail.ModeCampaign { + dialerOptions = config.GetConfig().CampaignMail + } else { + dialerOptions = config.GetConfig().Mail + } } if dialerOptions.Host == "-" { return nil diff --git a/worker/mails/mail_test.go b/worker/mails/mail_test.go index cedf8780dfe..e2516eb3e0e 100644 --- a/worker/mails/mail_test.go +++ b/worker/mails/mail_test.go @@ -269,6 +269,42 @@ QUIT assert.Equal(t, "yes", err.Error()) } }) + + t.Run("send campaign email", func(t *testing.T) { + sendMail = func(_ *job.TaskContext, opts *mail.Options, domain string) error { + assert.NotNil(t, opts.From) + assert.NotNil(t, opts.To) + assert.Len(t, opts.To, 1) + assert.Equal(t, "me@me", opts.To[0].Email) + assert.Equal(t, "noreply@"+inst.Domain, opts.From.Email) + assert.Equal(t, inst.Domain, domain) + return errors.New("yes") + } + defer func() { + sendMail = doSendMail + }() + msg, _ := job.NewMessage(mail.Options{ + Mode: mail.ModeCampaign, + Subject: "Awesome content", + Parts: []*mail.Part{ + { + Type: "text/plain", + Body: "foo", + }, + }, + Locale: "en", + }) + j := job.NewJob(inst, &job.JobRequest{ + Message: msg, + WorkerType: "sendmail", + }) + ctx, cancel := job.NewTaskContext("123", j, inst) + defer cancel() + err := SendMail(ctx) + if assert.Error(t, err) { + assert.Equal(t, "yes", err.Error()) + } + }) } func mailServer(t *testing.T, serverString string, clientStrings []string, expectedHeader map[string]string, send func(string, int) error) {