Skip to content

Commit

Permalink
feat: Add route to send campaign email from app
Browse files Browse the repository at this point in the history
  • Loading branch information
taratatach committed Feb 12, 2024
1 parent e378d4c commit 0019d13
Show file tree
Hide file tree
Showing 3 changed files with 194 additions and 5 deletions.
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 chek 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
35 changes: 34 additions & 1 deletion web/jobs/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,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
}
Expand Down Expand Up @@ -270,6 +277,29 @@ func (h *HTTPHandler) contactSupport(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}

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()
Expand Down Expand Up @@ -823,6 +853,7 @@ 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)
Expand Down Expand Up @@ -851,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)
}
Expand Down
104 changes: 100 additions & 4 deletions web/jobs/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ import (
"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, emailer emailer.Emailer) *httptest.Server {
func setupRouter(t *testing.T, inst *instance.Instance, emailerSvc emailer.Emailer) *httptest.Server {
t.Helper()

handler := echo.New()
Expand All @@ -42,7 +44,7 @@ func setupRouter(t *testing.T, inst *instance.Instance, emailer emailer.Emailer)
}
})

NewHTTPHandler(emailer).Register(group)
NewHTTPHandler(emailerSvc).Register(group)
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)

Expand Down Expand Up @@ -82,8 +84,8 @@ func TestJobs(t *testing.T) {
token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope,
"", time.Now())

emailer := emailer.NewMock(t)
ts := setupRouter(t, testInstance, emailer)
emailerSvc := emailer.NewMock(t)
ts := setupRouter(t, testInstance, emailerSvc)
ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler
t.Cleanup(ts.Close)

Expand Down Expand Up @@ -682,4 +684,98 @@ func TestJobs(t *testing.T) {
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"},
},
})
})
})
}

0 comments on commit 0019d13

Please sign in to comment.