From 6e1ebbc7bc6e81ac634222b3cd9fa5dba9ee0794 Mon Sep 17 00:00:00 2001 From: James Scott Date: Thu, 4 Dec 2025 19:27:21 +0000 Subject: [PATCH 1/3] experiment to send emails --- Makefile | 1 + workers/chime/cmd/job/constants.go | 210 ++++++++++++++++++ workers/chime/cmd/job/index.html | 189 ++++++++++++++++ workers/chime/cmd/job/main.go | 177 +++++++++++++++ workers/chime/go.mod | 14 ++ workers/chime/go.sum | 8 + workers/chime/resources/cloud-run-job.yaml | 14 ++ .../chime/resources/cloud-run-service.yaml | 9 + workers/chime/skaffold.yaml | 27 +++ 9 files changed, 649 insertions(+) create mode 100644 workers/chime/cmd/job/constants.go create mode 100644 workers/chime/cmd/job/index.html create mode 100644 workers/chime/cmd/job/main.go create mode 100644 workers/chime/go.mod create mode 100644 workers/chime/go.sum create mode 100644 workers/chime/resources/cloud-run-job.yaml create mode 100644 workers/chime/resources/cloud-run-service.yaml create mode 100644 workers/chime/skaffold.yaml diff --git a/Makefile b/Makefile index 87a3e6166..15aa565a3 100644 --- a/Makefile +++ b/Makefile @@ -426,6 +426,7 @@ go-workspace-setup: go-workspace-clean go work use ./lib/gen && \ go work use ./tools && \ go work use ./util && \ + go work use ./workers/chime && \ go work use ./workflows/steps/services/bcd_consumer && \ go work use ./workflows/steps/services/chromium_histogram_enums && \ go work use ./workflows/steps/services/developer_signals_consumer && \ diff --git a/workers/chime/cmd/job/constants.go b/workers/chime/cmd/job/constants.go new file mode 100644 index 000000000..98f86fd40 --- /dev/null +++ b/workers/chime/cmd/job/constants.go @@ -0,0 +1,210 @@ +package main + +const htmlEmail = ` + + + + + + WebStatus.dev Notification + + + + + + + + + +
+ + +
+ + +
+ + +

Weekly Digest

+
+ + +
+ + +

Hi there,

+

Here is your weekly update for your saved search "James's Picks". There were 408 total changes detected in the last 7 days.

+ + + +
+

Upcoming Milestones 🚀

+ + + + + + + +
+ + +
+

Recent Changes

+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+

+ ...and 403 other changes. +

+ + View Full Diff Log (408 items) + +
+ +
+ + + + +
+
+ + + +` + +const textEmail = ` +Subject: Weekly Digest for "James's Picks" (408 changes) + +WEBSTATUS.DEV WEEKLY DIGEST + +Hi there, +Here is your weekly update for your saved search "James's Picks". +There were 408 total changes detected in the last 7 days. + +UPCOMING MILESTONES 🚀 +- CSS :has() pseudo-class +[PROACTIVE] +Baseline Status: Projected to become "Widely Available" in ~14 days. +RECENT CHANGES +CSS Nesting [CHANGED]Baseline Status: Changed from "Limited" to "Newly Available"Developer Signals: Upvotes increased to 250 (+130)Temporal API [ADDED]Feature added to search results.Current Status: Limited AvailabilityWebSQL [REMOVED]Feature no longer matches search criteria....and 403 other changes.============================================================ VIEW FULL LOG: https://webstatus.dev/changes/view?event_id=evt-xyz-999Manage your subscription:View Search: https://www.google.com/search?q=https://webstatus.dev/saved-searches/ss-123Unsubscribe: https://www.google.com/search?q=https://webstatus.dev/subs/manage%3Funsubscribe%3Dsub-123© 2025 WebStatus.dev Project +` diff --git a/workers/chime/cmd/job/index.html b/workers/chime/cmd/job/index.html new file mode 100644 index 000000000..911214e78 --- /dev/null +++ b/workers/chime/cmd/job/index.html @@ -0,0 +1,189 @@ + + + + + + WebStatus.dev Notification + + + + + + + + + +
+ + +
+ + +
+ + +

Weekly Digest

+
+ + +
+ + +

Hi there,

+

Here is your weekly update for your saved search "James's Picks". There were 408 total changes detected in the last 7 days.

+ + + +
+

Upcoming Milestones 🚀

+ + + + + + + +
+ + +
+

Recent Changes

+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+

+ ...and 403 other changes. +

+ + View Full Diff Log (408 items) + +
+ +
+ + + + +
+
+ + + \ No newline at end of file diff --git a/workers/chime/cmd/job/main.go b/workers/chime/cmd/job/main.go new file mode 100644 index 000000000..68943190e --- /dev/null +++ b/workers/chime/cmd/job/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/oauth2/google" +) + +// Chime API Configuration +const ( + chimeAPIKey = "" // API Key from your GCP project (if required, often not for service account auth) + // Or use Autopush: "https://autopush-notifications-pa.sandbox.googleapis.com" + chimeBaseURL = "https://autopush-notifications-pa-googleapis.sandbox.google.com" + clientID = "webstatus_dev" + notificationType = "SUBSCRIPTION_NOTIFICATION" +) + +// Structs for JSON payload (matching Chime's NotifyTargetRequest structure) + +type NotifyTargetSyncRequest struct { + Notification Notification `json:"notification"` + Target Target `json:"target"` +} + +type Notification struct { + ClientID string `json:"client_id"` + TypeID string `json:"type_id"` + Payload Payload `json:"payload"` +} + +type Source struct { + SystemName string `json:"system_name"` +} + +type Payload struct { + TypeURL string `json:"@type"` + EmailMessage EmailMessage `json:"email_message"` +} + +type EmailMessage struct { + FromAddress string `json:"from_address"` + Subject string `json:"subject"` + BodyPart []BodyPart `json:"body_part"` + CcRecipient []string `json:"cc_recipient,omitempty"` + BccRecipient []string `json:"bcc_recipient,omitempty"` +} + +type BodyPart struct { + Content string `json:"content"` + ContentType string `json:"content_type"` +} + +type Target struct { + ChannelType string `json:"channel_type"` + DeliveryAddress DeliveryAddress `json:"delivery_address"` +} + +type DeliveryAddress struct { + EmailAddress EmailAddress `json:"email_address"` +} + +type EmailAddress struct { + ToAddress string `json:"to_address"` +} + +// SendChimeEmail sends an email using Chime's NotifyTargetSync API +func SendChimeEmail(ctx context.Context, toAddress, fromAddress, subject, htmlBody, plainBody string, bcc []string) error { + // 1. Get OAuth Token from Metadata Server + scopes := []string{"https://www.googleapis.com/auth/notifications"} + creds, err := google.FindDefaultCredentials(ctx, scopes...) + if err != nil { + return fmt.Errorf("failed to find default credentials: %w", err) + } + + token, err := creds.TokenSource.Token() + if err != nil { + return fmt.Errorf("failed to retrieve access token: %w", err) + } + + // 2. Construct the Request Body + reqBody := NotifyTargetSyncRequest{ + Notification: Notification{ + ClientID: clientID, + TypeID: notificationType, + Payload: Payload{ + TypeURL: "type.googleapis.com/notifications.backend.common.message.RenderedMessage", + EmailMessage: EmailMessage{ + FromAddress: fromAddress, // Ensure this complies with GMR sender rules + Subject: subject, + BodyPart: []BodyPart{ + { + Content: plainBody, + ContentType: "text/plain", + }, + { + Content: htmlBody, + ContentType: "text/html", + }, + }, + CcRecipient: nil, + BccRecipient: bcc, + }, + }, + }, + Target: Target{ + ChannelType: "EMAIL", + DeliveryAddress: DeliveryAddress{ + EmailAddress: EmailAddress{ + ToAddress: toAddress, + }, + }, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + // 3. Make the HTTP POST Request + apiURL := fmt.Sprintf("%s/v1/notifytargetsync", chimeBaseURL) + if chimeAPIKey != "" { + apiURL = fmt.Sprintf("%s?key=%s", apiURL, chimeAPIKey) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request to Chime: %w", err) + } + defer resp.Body.Close() + + // 4. Handle the Response + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Chime API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + fmt.Println("Chime notification sent successfully to", toAddress) + // Optionally parse the NotifyTargetSyncResponse JSON + var responseBody map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&responseBody); err == nil { + fmt.Printf("Response: %+v\n", responseBody) + } + return nil +} + +func main() { + ctx := context.Background() + + // Example Usage: + to := "" + from := "noreply-webstatus-dev@google.com" + bcc := []string{""} + subject := "Test Notification from Cloud Run" + // body := "

Hello from Chime!

This is a test email sent via NotifyTargetSync from Cloud Run.

" + + if err := SendChimeEmail(ctx, to, from, subject, htmlEmail, textEmail, bcc); err != nil { + fmt.Printf("Error sending email: %v\n", err) + } else { + fmt.Println("Email sending process initiated.") + } +} diff --git a/workers/chime/go.mod b/workers/chime/go.mod new file mode 100644 index 000000000..d87cabc23 --- /dev/null +++ b/workers/chime/go.mod @@ -0,0 +1,14 @@ +module github.com/GoogleChrome/webstatus.dev/workers/chime + +go 1.25.4 + +replace github.com/GoogleChrome/webstatus.dev/lib => ../../lib + +replace github.com/GoogleChrome/webstatus.dev/lib/gen => ../../lib/gen + +require golang.org/x/oauth2 v0.33.0 + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/workers/chime/go.sum b/workers/chime/go.sum new file mode 100644 index 000000000..089b348bd --- /dev/null +++ b/workers/chime/go.sum @@ -0,0 +1,8 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/workers/chime/resources/cloud-run-job.yaml b/workers/chime/resources/cloud-run-job.yaml new file mode 100644 index 000000000..08b9da939 --- /dev/null +++ b/workers/chime/resources/cloud-run-job.yaml @@ -0,0 +1,14 @@ +apiVersion: run.googleapis.com/v1 +kind: Job +metadata: + name: chime-test-job-jcsiii + annotations: + run.googleapis.com/launch-stage: BETA +spec: + template: + spec: + template: + spec: + serviceAccountName: emailer-job-staging@webstatus-dev-internal-staging.iam.gserviceaccount.com + containers: + - image: europe-west1-docker.pkg.dev/webstatus-dev-internal-staging/staging-docker-repository/chime_test_image diff --git a/workers/chime/resources/cloud-run-service.yaml b/workers/chime/resources/cloud-run-service.yaml new file mode 100644 index 000000000..5a7b29084 --- /dev/null +++ b/workers/chime/resources/cloud-run-service.yaml @@ -0,0 +1,9 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: cloud-run-service-name +spec: + template: + spec: + containers: + - image: gcr.io/cloudrun/hello diff --git a/workers/chime/skaffold.yaml b/workers/chime/skaffold.yaml new file mode 100644 index 000000000..d780f6267 --- /dev/null +++ b/workers/chime/skaffold.yaml @@ -0,0 +1,27 @@ +apiVersion: skaffold/v4beta9 +kind: Config +metadata: + name: chime-config +profiles: + - name: europe-west1 + build: + artifacts: + - image: europe-west1-docker.pkg.dev/webstatus-dev-internal-staging/staging-docker-repository/chime_test_image + context: ../.. + runtimeType: go + docker: + dockerfile: images/go_service.Dockerfile + buildArgs: + service_dir: workers/chime + MAIN_BINARY: job + local: + useBuildkit: true + push: true + platforms: ['linux/amd64'] + manifests: + rawYaml: + - resources/cloud-run-job.yaml + deploy: + cloudrun: + region: europe-west1 + projectid: webstatus-dev-internal-staging From 22e7590fc5dd564788a1646ceb0584be3ca1074c Mon Sep 17 00:00:00 2001 From: James Scott Date: Wed, 31 Dec 2025 03:47:51 +0000 Subject: [PATCH 2/3] refactoring --- workers/chime/cmd/job/main.go | 343 ++++++++++++++++++++++++++-------- workers/chime/go.mod | 1 + workers/chime/go.sum | 2 + 3 files changed, 271 insertions(+), 75 deletions(-) diff --git a/workers/chime/cmd/job/main.go b/workers/chime/cmd/job/main.go index 68943190e..5d33119dd 100644 --- a/workers/chime/cmd/job/main.go +++ b/workers/chime/cmd/job/main.go @@ -4,174 +4,367 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "strings" "time" + "github.com/google/uuid" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) -// Chime API Configuration +// ChimeEnv type for environment selection +type ChimeEnv int + +const ( + // EnvAutopush uses the autopush environment + EnvAutopush ChimeEnv = iota + // EnvStaging uses the staging environment + EnvStaging + // EnvProd uses the production environment + EnvProd +) + +var chimeBaseURLs = map[ChimeEnv]string{ + EnvAutopush: "https://autopush-notifications-pa-googleapis.sandbox.google.com", + EnvProd: "https://notifications-pa.googleapis.com", +} + +// ClientID and other constants const ( - chimeAPIKey = "" // API Key from your GCP project (if required, often not for service account auth) - // Or use Autopush: "https://autopush-notifications-pa.sandbox.googleapis.com" - chimeBaseURL = "https://autopush-notifications-pa-googleapis.sandbox.google.com" clientID = "webstatus_dev" notificationType = "SUBSCRIPTION_NOTIFICATION" + defaultFromAddr = "noreply-webstatus-dev@google.com" ) -// Structs for JSON payload (matching Chime's NotifyTargetRequest structure) +// Sentinel Errors +var ( + ErrPermanentUser = errors.New("permanent error due to user/target issue") + ErrPermanentSystem = errors.New("permanent error due to system/config issue") + ErrTransient = errors.New("transient error, can be retried") + ErrDuplicate = errors.New("duplicate notification") +) + +// EmailSender Interface +type EmailSender interface { + Send(ctx context.Context, id string, to string, subject string, htmlBody string) error +} + +// HTTPClient interface to allow mocking http.Client +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} +// ChimeSender Struct +type ChimeSender struct { + bcc []string + tokenSource oauth2.TokenSource + httpClient HTTPClient // Use the interface + fromAddress string + baseURL string +} + +// NewChimeSender creates a new ChimeSender instance +func NewChimeSender(ctx context.Context, env ChimeEnv, bcc []string, fromAddr string, customHTTPClient HTTPClient) (*ChimeSender, error) { + baseURL, ok := chimeBaseURLs[env] + if !ok { + return nil, fmt.Errorf("%w: invalid ChimeEnv: %v", ErrPermanentSystem, env) + } + + ts, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/notifications") + if err != nil { + return nil, fmt.Errorf("%w: failed to find default credentials: %v", ErrPermanentSystem, err) + } + + var httpClient HTTPClient = customHTTPClient + if httpClient == nil { + client := oauth2.NewClient(ctx, ts.TokenSource) + client.Timeout = 30 * time.Second + httpClient = client + } + + if fromAddr == "" { + fromAddr = defaultFromAddr + } + + return &ChimeSender{ + bcc: bcc, + tokenSource: ts.TokenSource, + httpClient: httpClient, + fromAddress: fromAddr, + baseURL: baseURL, + }, nil +} + +// --- Structs for JSON payload --- type NotifyTargetSyncRequest struct { Notification Notification `json:"notification"` Target Target `json:"target"` } - type Notification struct { - ClientID string `json:"client_id"` - TypeID string `json:"type_id"` - Payload Payload `json:"payload"` + ClientID string `json:"client_id"` + ExternalID string `json:"external_id"` + TypeID string `json:"type_id"` + Payload Payload `json:"payload"` } - type Source struct { SystemName string `json:"system_name"` } - type Payload struct { TypeURL string `json:"@type"` EmailMessage EmailMessage `json:"email_message"` } - type EmailMessage struct { FromAddress string `json:"from_address"` Subject string `json:"subject"` BodyPart []BodyPart `json:"body_part"` - CcRecipient []string `json:"cc_recipient,omitempty"` BccRecipient []string `json:"bcc_recipient,omitempty"` } - type BodyPart struct { Content string `json:"content"` ContentType string `json:"content_type"` } - type Target struct { ChannelType string `json:"channel_type"` DeliveryAddress DeliveryAddress `json:"delivery_address"` } - type DeliveryAddress struct { EmailAddress EmailAddress `json:"email_address"` } - type EmailAddress struct { ToAddress string `json:"to_address"` } +type NotifyTargetSyncResponse struct { + ExternalId string `json:"externalId"` + Identifier string `json:"identifier"` + Details struct { + Outcome string `json:"outcome"` + Reason string `json:"reason"` + } `json:"details"` +} + +// --- Send method and its helpers --- + +// Send implements the EmailSender interface for ChimeSender +func (s *ChimeSender) Send(ctx context.Context, id string, to string, subject string, htmlBody string) error { + if id == "" { + return fmt.Errorf("%w: id (externalID) cannot be empty", ErrPermanentSystem) + } -// SendChimeEmail sends an email using Chime's NotifyTargetSync API -func SendChimeEmail(ctx context.Context, toAddress, fromAddress, subject, htmlBody, plainBody string, bcc []string) error { - // 1. Get OAuth Token from Metadata Server - scopes := []string{"https://www.googleapis.com/auth/notifications"} - creds, err := google.FindDefaultCredentials(ctx, scopes...) + reqBodyData, err := s.buildRequestBody(id, to, subject, htmlBody) if err != nil { - return fmt.Errorf("failed to find default credentials: %w", err) + return err } - token, err := creds.TokenSource.Token() + httpReq, err := s.createHTTPRequest(ctx, reqBodyData) if err != nil { - return fmt.Errorf("failed to retrieve access token: %w", err) + return err } - // 2. Construct the Request Body + resp, bodyBytes, err := s.executeRequest(httpReq) + if err != nil { + return err // errors from executeRequest are already wrapped + } + defer resp.Body.Close() + + return s.handleResponse(resp, bodyBytes, id) +} + +func (s *ChimeSender) buildRequestBody(id string, to string, subject string, htmlBody string) ([]byte, error) { reqBody := NotifyTargetSyncRequest{ Notification: Notification{ - ClientID: clientID, - TypeID: notificationType, + ClientID: clientID, + ExternalID: id, + TypeID: notificationType, Payload: Payload{ TypeURL: "type.googleapis.com/notifications.backend.common.message.RenderedMessage", EmailMessage: EmailMessage{ - FromAddress: fromAddress, // Ensure this complies with GMR sender rules + FromAddress: s.fromAddress, Subject: subject, BodyPart: []BodyPart{ - { - Content: plainBody, - ContentType: "text/plain", - }, - { - Content: htmlBody, - ContentType: "text/html", - }, + {Content: htmlBody, ContentType: "text/html"}, }, - CcRecipient: nil, - BccRecipient: bcc, + BccRecipient: s.bcc, }, }, }, Target: Target{ ChannelType: "EMAIL", DeliveryAddress: DeliveryAddress{ - EmailAddress: EmailAddress{ - ToAddress: toAddress, - }, + EmailAddress: EmailAddress{ToAddress: to}, }, }, } - jsonData, err := json.Marshal(reqBody) if err != nil { - return fmt.Errorf("failed to marshal request body: %w", err) + return nil, fmt.Errorf("%w: failed to marshal request body: %v", ErrPermanentSystem, err) } + return jsonData, nil +} - // 3. Make the HTTP POST Request - apiURL := fmt.Sprintf("%s/v1/notifytargetsync", chimeBaseURL) - if chimeAPIKey != "" { - apiURL = fmt.Sprintf("%s?key=%s", apiURL, chimeAPIKey) +func (s *ChimeSender) createHTTPRequest(ctx context.Context, body []byte) (*http.Request, error) { + apiURL := fmt.Sprintf("%s/v1/notifytargetsync", s.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("%w: failed to create HTTP request: %v", ErrPermanentSystem, err) } - req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData)) + token, err := s.tokenSource.Token() if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) + return nil, fmt.Errorf("%w: failed to retrieve access token: %v", ErrPermanentSystem, err) } - req.Header.Set("Authorization", "Bearer "+token.AccessToken) req.Header.Set("Content-Type", "application/json") + return req, nil +} - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) +func (s *ChimeSender) executeRequest(req *http.Request) (*http.Response, []byte, error) { + resp, err := s.httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to send request to Chime: %w", err) + return nil, nil, fmt.Errorf("%w: network error sending to Chime: %v", ErrTransient, err) } - defer resp.Body.Close() - // 4. Handle the Response - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("Chime API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + resp.Body.Close() // Close body if ReadAll fails + return nil, nil, fmt.Errorf("%w: failed to read response body: %v", ErrTransient, err) + } + return resp, bodyBytes, nil +} + +func (s *ChimeSender) handleResponse(resp *http.Response, bodyBytes []byte, externalID string) error { + bodyStr := string(bodyBytes) + + if resp.StatusCode == http.StatusConflict { // 409 + return fmt.Errorf("%w: external_id %s: %s", ErrDuplicate, externalID, bodyStr) + } + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return classifyHTTPClientError(resp.StatusCode, bodyStr) + } else if resp.StatusCode >= 500 { + return fmt.Errorf("%w: Chime server error (%d): %s", ErrTransient, resp.StatusCode, bodyStr) + } + + var responseBody NotifyTargetSyncResponse + if err := json.Unmarshal(bodyBytes, &responseBody); err != nil { + // Chime accepted it, but response is not what we expected. Log and treat as success. + fmt.Printf("Chime call OK (ExternalID: %s), but failed to parse response body: %v. Body: %s\n", externalID, err, bodyStr) + return nil + } + + return classifyChimeOutcome(externalID, responseBody) +} + +func classifyHTTPClientError(statusCode int, bodyStr string) error { + switch statusCode { + case http.StatusBadRequest: // 400 + return fmt.Errorf("%w: bad request (400): %s", ErrPermanentSystem, bodyStr) + case http.StatusUnauthorized: // 401 + return fmt.Errorf("%w: unauthorized (401): %s", ErrPermanentSystem, bodyStr) + case http.StatusForbidden: // 403 + return fmt.Errorf("%w: forbidden (403): %s", ErrPermanentSystem, bodyStr) + default: + return fmt.Errorf("%w: client error (%d): %s", ErrPermanentSystem, statusCode, bodyStr) + } +} + +func classifyChimeOutcome(externalID string, responseBody NotifyTargetSyncResponse) error { + outcome := responseBody.Details.Outcome + reason := responseBody.Details.Reason + chimeID := responseBody.Identifier + fmt.Printf("Chime Response: ExternalID: %s, ChimeID: %s, Outcome: %s, Reason: %s\n", externalID, chimeID, outcome, reason) + + switch outcome { + case "SENT": + return nil // Success + case "PREFERENCE_DROPPED", "INVALID_AUTH_SUB_TOKEN_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentUser, outcome, reason) + case "EXPLICITLY_DROPPED", "MESSAGE_TOO_LARGE_DROPPED", "INVALID_REQUEST_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentSystem, outcome, reason) + case "DELIVERY_FAILURE_DROPPED": + if isUserCausedDeliveryFailure(reason) { + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentUser, outcome, reason) + } else if isSystemCausedDeliveryFailure(reason) { + return fmt.Errorf("%w: outcome %s, reason: %s", ErrPermanentSystem, outcome, reason) + } else { + return fmt.Errorf("%w: outcome %s, reason: %s", ErrTransient, outcome, reason) + } + case "QUOTA_DROPPED": + return fmt.Errorf("%w: outcome %s, reason: %s", ErrTransient, outcome, reason) + default: // Unknown outcome + return fmt.Errorf("%w: unknown outcome %s, reason: %s", ErrTransient, outcome, reason) + } +} + +func isUserCausedDeliveryFailure(reason string) bool { + userKeywords := []string{"invalid_mailbox", "no such user", "invalid_domain", "domain not found", "unroutable address"} + lowerReason := strings.ToLower(reason) + for _, kw := range userKeywords { + if strings.Contains(lowerReason, kw) { + return true + } + } + return strings.Contains(lowerReason, "perm_fail") && !isSystemCausedDeliveryFailure(reason) +} + +func isSystemCausedDeliveryFailure(reason string) bool { + systemKeywords := []string{"perm_fail_sender_denied", "mail loop"} + lowerReason := strings.ToLower(reason) + for _, kw := range systemKeywords { + if strings.Contains(lowerReason, kw) { + return true + } } + return false +} - fmt.Println("Chime notification sent successfully to", toAddress) - // Optionally parse the NotifyTargetSyncResponse JSON - var responseBody map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&responseBody); err == nil { - fmt.Printf("Response: %+v\n", responseBody) +func handleSendResult(err error) { + if err != nil { + fmt.Printf("\nError sending email: %v\n", err) + if errors.Is(err, ErrDuplicate) { + fmt.Println("Result: This was a DUPLICATE send.") + } else if errors.Is(err, ErrPermanentUser) { + fmt.Println("Result: PERMANENT error due to USER issue.") + } else if errors.Is(err, ErrPermanentSystem) { + fmt.Println("Result: PERMANENT error due to SYSTEM issue.") + } else if errors.Is(err, ErrTransient) { + fmt.Println("Result: TRANSIENT error.") + } else { + fmt.Println("Result: Unknown error type.") + } + } else { + fmt.Println("\nEmail sending process initiated and reported as SENT.") } - return nil } +// --- Main function for demonstration --- func main() { ctx := context.Background() - // Example Usage: + // Initialize ChimeSender + bccList := []string{} // Add BCC addresses if needed + sender, err := NewChimeSender(ctx, EnvAutopush, bccList, "", nil) // Use default from addr, no custom HTTP client + if err != nil { + fmt.Printf("Failed to create ChimeSender: %v\n", err) + return + } + + // Example Send + myExternalID := uuid.New().String() to := "" - from := "noreply-webstatus-dev@google.com" - bcc := []string{""} - subject := "Test Notification from Cloud Run" - // body := "

Hello from Chime!

This is a test email sent via NotifyTargetSync from Cloud Run.

" + subject := "Test from Refactored ChimeSender" + htmlEmail := "

Hello from Refactored ChimeSender!

This email was sent using the refactored ChimeSender struct.

" - if err := SendChimeEmail(ctx, to, from, subject, htmlEmail, textEmail, bcc); err != nil { - fmt.Printf("Error sending email: %v\n", err) - } else { - fmt.Println("Email sending process initiated.") - } + fmt.Println("--- First Send Attempt ---") + err = sender.Send(ctx, myExternalID, to, subject, htmlEmail) + handleSendResult(err) + + // Example of a duplicate send attempt + fmt.Println("\n--- Second Send Attempt (Duplicate) ---") + // Using the SAME myExternalID, to, subject, htmlBody + err = sender.Send(ctx, myExternalID, to, subject, htmlEmail) + handleSendResult(err) } diff --git a/workers/chime/go.mod b/workers/chime/go.mod index d87cabc23..b2c10c826 100644 --- a/workers/chime/go.mod +++ b/workers/chime/go.mod @@ -10,5 +10,6 @@ require golang.org/x/oauth2 v0.33.0 require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/google/uuid v1.6.0 golang.org/x/sys v0.38.0 // indirect ) diff --git a/workers/chime/go.sum b/workers/chime/go.sum index 089b348bd..60e31c94e 100644 --- a/workers/chime/go.sum +++ b/workers/chime/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= From 08356d276b0c448936ee88a4ffb2f8af9178d11c Mon Sep 17 00:00:00 2001 From: James Scott Date: Thu, 5 Feb 2026 20:34:21 +0000 Subject: [PATCH 3/3] more tests --- .../chime/resources/cloud-run-job-prod.yaml | 14 ++++++++++++ .../chime/resources/cloud-run-service.yaml | 9 -------- workers/chime/skaffold.yaml | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 workers/chime/resources/cloud-run-job-prod.yaml delete mode 100644 workers/chime/resources/cloud-run-service.yaml diff --git a/workers/chime/resources/cloud-run-job-prod.yaml b/workers/chime/resources/cloud-run-job-prod.yaml new file mode 100644 index 000000000..6b16968d0 --- /dev/null +++ b/workers/chime/resources/cloud-run-job-prod.yaml @@ -0,0 +1,14 @@ +apiVersion: run.googleapis.com/v1 +kind: Job +metadata: + name: chime-test-job-jcsiii + annotations: + run.googleapis.com/launch-stage: BETA +spec: + template: + spec: + template: + spec: + serviceAccountName: emailer-job-prod@webstatus-dev-internal-prod.iam.gserviceaccount.com + containers: + - image: europe-west1-docker.pkg.dev/webstatus-dev-internal-prod/prod-docker-repository/chime_test_image diff --git a/workers/chime/resources/cloud-run-service.yaml b/workers/chime/resources/cloud-run-service.yaml deleted file mode 100644 index 5a7b29084..000000000 --- a/workers/chime/resources/cloud-run-service.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: cloud-run-service-name -spec: - template: - spec: - containers: - - image: gcr.io/cloudrun/hello diff --git a/workers/chime/skaffold.yaml b/workers/chime/skaffold.yaml index d780f6267..f692d338c 100644 --- a/workers/chime/skaffold.yaml +++ b/workers/chime/skaffold.yaml @@ -25,3 +25,25 @@ profiles: cloudrun: region: europe-west1 projectid: webstatus-dev-internal-staging + - name: europe-west1-prod + build: + artifacts: + - image: europe-west1-docker.pkg.dev/webstatus-dev-internal-prod/prod-docker-repository/chime_test_image + context: ../.. + runtimeType: go + docker: + dockerfile: images/go_service.Dockerfile + buildArgs: + service_dir: workers/chime + MAIN_BINARY: job + local: + useBuildkit: true + push: true + platforms: ['linux/amd64'] + manifests: + rawYaml: + - resources/cloud-run-job-prod.yaml + deploy: + cloudrun: + region: europe-west1 + projectid: webstatus-dev-internal-prod