Skip to content

Commit 5d336cd

Browse files
committed
feat(email): Add Chime email sending service
Implements a new email sending service using the Chime API. - Adds a new Chime client in `lib/email/chime` for handling API requests, authentication, and error classification (transient, permanent user, permanent system). - Introduces an adapter in `lib/email/chime/chimeadapters` to integrate the new client with the existing email worker. - Updates the email worker `Send` interface to include a unique ID per message, which is passed to Chime's `external_id` field for deduplication in the future.
1 parent b67a8e4 commit 5d336cd

File tree

6 files changed

+744
-5
lines changed

6 files changed

+744
-5
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package chimeadapters
16+
17+
import (
18+
"context"
19+
"errors"
20+
21+
"github.com/GoogleChrome/webstatus.dev/lib/email/chime"
22+
"github.com/GoogleChrome/webstatus.dev/lib/workertypes"
23+
)
24+
25+
type EmailSender interface {
26+
Send(ctx context.Context, id string, to string, subject string, htmlBody string) error
27+
}
28+
29+
type EmailWorkerChimeAdapter struct {
30+
chimeSender EmailSender
31+
}
32+
33+
// NewEmailWorkerChimeAdapter creates a new adapter for the email worker to use Chime.
34+
func NewEmailWorkerChimeAdapter(chimeSender EmailSender) *EmailWorkerChimeAdapter {
35+
return &EmailWorkerChimeAdapter{
36+
chimeSender: chimeSender,
37+
}
38+
}
39+
40+
// Send implements the EmailSender interface for the email worker.
41+
func (a *EmailWorkerChimeAdapter) Send(ctx context.Context, id string, to string,
42+
subject string, htmlBody string) error {
43+
err := a.chimeSender.Send(ctx, id, to, subject, htmlBody)
44+
if err != nil {
45+
if errors.Is(err, chime.ErrPermanentUser) {
46+
return errors.Join(workertypes.ErrUnrecoverableUserFailureEmailSending, err)
47+
} else if errors.Is(err, chime.ErrPermanentSystem) {
48+
return errors.Join(workertypes.ErrUnrecoverableSystemFailureEmailSending, err)
49+
} else if errors.Is(err, chime.ErrDuplicate) {
50+
return errors.Join(workertypes.ErrUnrecoverableSystemFailureEmailSending, err)
51+
}
52+
53+
// Will be recorded as a transient error
54+
return err
55+
}
56+
57+
return nil
58+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package chimeadapters
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/GoogleChrome/webstatus.dev/lib/email/chime"
23+
"github.com/GoogleChrome/webstatus.dev/lib/workertypes"
24+
)
25+
26+
// mockChimeSender is a mock implementation of the EmailSender for testing.
27+
type mockChimeSender struct {
28+
sendErr error
29+
}
30+
31+
func (m *mockChimeSender) Send(_ context.Context, _ string, _ string, _ string, _ string) error {
32+
return m.sendErr
33+
}
34+
35+
var errTest = errors.New("test error")
36+
37+
func TestEmailWorkerChimeAdapter_Send(t *testing.T) {
38+
ctx := context.Background()
39+
testCases := []struct {
40+
name string
41+
chimeError error
42+
expectedError error
43+
}{
44+
{
45+
name: "Success",
46+
chimeError: nil,
47+
expectedError: nil,
48+
},
49+
{
50+
name: "Permanent User Error",
51+
chimeError: chime.ErrPermanentUser,
52+
expectedError: workertypes.ErrUnrecoverableUserFailureEmailSending,
53+
},
54+
{
55+
name: "Permanent System Error",
56+
chimeError: chime.ErrPermanentSystem,
57+
expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending,
58+
},
59+
{
60+
name: "Duplicate Error",
61+
chimeError: chime.ErrDuplicate,
62+
expectedError: workertypes.ErrUnrecoverableSystemFailureEmailSending,
63+
},
64+
{
65+
name: "Transient Error",
66+
chimeError: chime.ErrTransient,
67+
expectedError: chime.ErrTransient, // Should be passed through
68+
},
69+
{
70+
name: "Other Error",
71+
chimeError: errTest,
72+
expectedError: errTest, // Should be passed through
73+
},
74+
}
75+
76+
for _, tc := range testCases {
77+
t.Run(tc.name, func(t *testing.T) {
78+
// Setup
79+
mockSender := &mockChimeSender{sendErr: tc.chimeError}
80+
adapter := NewEmailWorkerChimeAdapter(mockSender)
81+
82+
// Execute
83+
err := adapter.Send(ctx, "test-id", "to@example.com", "Test Subject", "<p>Hello</p>")
84+
85+
// Verify
86+
if tc.expectedError != nil {
87+
if err == nil {
88+
t.Fatal("Expected an error, but got nil")
89+
}
90+
if !errors.Is(err, tc.expectedError) {
91+
t.Errorf("Expected error wrapping %v, but got %v", tc.expectedError, err)
92+
}
93+
} else {
94+
if err != nil {
95+
t.Errorf("Expected no error, but got %v", err)
96+
}
97+
}
98+
})
99+
}
100+
}

0 commit comments

Comments
 (0)