diff --git a/lib/event/emailjob/v1/types.go b/lib/event/emailjob/v1/types.go index 3fd1dd2b8..831f6620d 100644 --- a/lib/event/emailjob/v1/types.go +++ b/lib/event/emailjob/v1/types.go @@ -30,6 +30,8 @@ type EmailJobEvent struct { SummaryRaw []byte `json:"summary_raw"` // Metadata contains additional metadata about the event. Metadata EmailJobEventMetadata `json:"metadata"` + // ChannelID is the ID of the channel associated with this job. + ChannelID string `json:"channel_id"` } type EmailJobEventMetadata struct { diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery.go b/lib/gcppubsub/gcppubsubadapters/push_delivery.go index d919a694f..0a42bd39c 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery.go @@ -49,6 +49,7 @@ func (p *PushDeliveryPublisher) PublishEmailJob(ctx context.Context, job workert Frequency: v1.ToJobFrequency(job.Metadata.Frequency), GeneratedAt: job.Metadata.GeneratedAt, }, + ChannelID: job.ChannelID, }) if err != nil { return err diff --git a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go index 49b825596..e61598341 100644 --- a/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go +++ b/lib/gcppubsub/gcppubsubadapters/push_delivery_test.go @@ -111,6 +111,7 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { Frequency: workertypes.FrequencyMonthly, GeneratedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), }, + ChannelID: "chan-1", } err := publisher.PublishEmailJob(context.Background(), job) @@ -141,6 +142,7 @@ func TestPushDeliveryPublisher_PublishEmailJob(t *testing.T) { "frequency": "MONTHLY", "generated_at": "2025-01-01T12:00:00Z", }, + "channel_id": "chan-1", }, } diff --git a/lib/gcpspanner/spanneradapters/push_delivery.go b/lib/gcpspanner/spanneradapters/push_delivery.go index b10b4c498..54bb8545b 100644 --- a/lib/gcpspanner/spanneradapters/push_delivery.go +++ b/lib/gcpspanner/spanneradapters/push_delivery.go @@ -59,6 +59,7 @@ func (f *PushDeliverySubscriberFinder) FindSubscribers(ctx context.Context, sear UserID: dest.UserID, Triggers: convertSpannerTriggersToJobTriggers(dest.Triggers), EmailAddress: dest.EmailConfig.Address, + ChannelID: dest.ChannelID, }) } } diff --git a/lib/gcpspanner/spanneradapters/push_delivery_test.go b/lib/gcpspanner/spanneradapters/push_delivery_test.go index 9b73e13c6..8e4cf46ad 100644 --- a/lib/gcpspanner/spanneradapters/push_delivery_test.go +++ b/lib/gcpspanner/spanneradapters/push_delivery_test.go @@ -94,6 +94,7 @@ func TestFindSubscribers(t *testing.T) { workertypes.FeaturePromotedToNewly, workertypes.FeaturePromotedToWidely, }, + ChannelID: "chan-1", }, }, }, @@ -140,6 +141,7 @@ func TestFindSubscribers(t *testing.T) { Triggers: []workertypes.JobTrigger{ workertypes.BrowserImplementationAnyComplete, }, + ChannelID: "chan-1", }, }, }, @@ -210,6 +212,7 @@ func TestFindSubscribers(t *testing.T) { Triggers: []workertypes.JobTrigger{ "", // Unknown triggers map to empty string/zero value in current implementation }, + ChannelID: "chan-1", }, }, }, diff --git a/lib/workertypes/types.go b/lib/workertypes/types.go index 05d3804f7..fef3c93db 100644 --- a/lib/workertypes/types.go +++ b/lib/workertypes/types.go @@ -603,6 +603,7 @@ type EmailSubscriber struct { UserID string Triggers []JobTrigger EmailAddress string + ChannelID string } // SubscriberSet groups subscribers by channel type to avoid runtime type assertions. @@ -633,6 +634,7 @@ type DispatchEventMetadata struct { type EmailDeliveryJob struct { SubscriptionID string RecipientEmail string + ChannelID string // SummaryRaw is the opaque JSON payload describing the event. SummaryRaw []byte // Metadata contains context for links and tracking. diff --git a/workers/email/go.mod b/workers/email/go.mod index 7c9352e96..050017a66 100644 --- a/workers/email/go.mod +++ b/workers/email/go.mod @@ -6,7 +6,10 @@ replace github.com/GoogleChrome/webstatus.dev/lib => ../../lib replace github.com/GoogleChrome/webstatus.dev/lib/gen => ../../lib/gen -require github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 +require ( + github.com/GoogleChrome/webstatus.dev/lib v0.0.0-00010101000000-000000000000 + github.com/google/go-cmp v0.7.0 +) require ( cel.dev/expr v0.25.1 // indirect @@ -22,21 +25,33 @@ require ( github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect diff --git a/workers/email/go.sum b/workers/email/go.sum index af0a25e77..7f043218c 100644 --- a/workers/email/go.sum +++ b/workers/email/go.sum @@ -637,6 +637,7 @@ github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -648,6 +649,9 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -735,6 +739,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= @@ -755,8 +761,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -891,8 +905,11 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= @@ -906,8 +923,11 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= @@ -916,6 +936,8 @@ github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuz github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -935,12 +957,22 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= @@ -966,6 +998,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= @@ -978,10 +1012,12 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -998,8 +1034,12 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632 h1:9t4b2caqsFRUWK4k9+lM/PkxLTCvo46ZQPVaoHPaCxY= github.com/web-platform-tests/wpt.fyi v0.0.0-20251118162843-54f805c8a632/go.mod h1:YpCvJq5JKA+aa4+jwK1S2uQ7r0horYVl6DHcl/G9S3s= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1682,6 +1722,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/workers/email/pkg/sender/sender.go b/workers/email/pkg/sender/sender.go new file mode 100644 index 000000000..940e653ae --- /dev/null +++ b/workers/email/pkg/sender/sender.go @@ -0,0 +1,87 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sender + +import ( + "context" + "log/slog" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type EmailSender interface { + Send(ctx context.Context, to string, subject string, htmlBody string) error +} + +type ChannelStateManager interface { + RecordSuccess(ctx context.Context, channelID string) error + RecordFailure(ctx context.Context, channelID string, err error) error +} + +type TemplateRenderer interface { + RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) +} + +type Sender struct { + sender EmailSender + stateManager ChannelStateManager + renderer TemplateRenderer +} + +func NewSender( + sender EmailSender, + stateManager ChannelStateManager, + renderer TemplateRenderer, +) *Sender { + return &Sender{ + sender: sender, + stateManager: stateManager, + renderer: renderer, + } +} + +func (s *Sender) ProcessMessage(ctx context.Context, job workertypes.EmailDeliveryJob) error { + // 1. Render (Parsing happens inside RenderDigest implementation) + subject, body, err := s.renderer.RenderDigest(job) + if err != nil { + slog.ErrorContext(ctx, "failed to render email", "subscription_id", job.SubscriptionID, "error", err) + if err := s.stateManager.RecordFailure(ctx, job.ChannelID, err); err != nil { + slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", err) + } + // Rendering errors might be transient or permanent. Assuming permanent for template bugs. + return nil + } + + // 2. Send + if err := s.sender.Send(ctx, job.RecipientEmail, subject, body); err != nil { + slog.ErrorContext(ctx, "failed to send email", "recipient", job.RecipientEmail, "error", err) + // Record failure in DB + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err); dbErr != nil { + slog.ErrorContext(ctx, "failed to record channel failure", "channel_id", job.ChannelID, "error", dbErr) + } + + // Return error to NACK the message and retry sending? + // Sending failures (network, rate limit) are often transient. + return err + } + + // 3. Success + if err := s.stateManager.RecordSuccess(ctx, job.ChannelID); err != nil { + // Non-critical error, but good to log + slog.WarnContext(ctx, "failed to record channel success", "channel_id", job.ChannelID, "error", err) + } + + return nil +} diff --git a/workers/email/pkg/sender/sender_test.go b/workers/email/pkg/sender/sender_test.go new file mode 100644 index 000000000..10218f298 --- /dev/null +++ b/workers/email/pkg/sender/sender_test.go @@ -0,0 +1,210 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sender + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +// --- Mocks --- + +type mockEmailSender struct { + sentCalls []sentCall + sendErr error +} + +type sentCall struct { + to string + subject string + body string +} + +func (m *mockEmailSender) Send(_ context.Context, to, subject, body string) error { + m.sentCalls = append(m.sentCalls, sentCall{to, subject, body}) + + return m.sendErr +} + +type mockChannelStateManager struct { + successCalls []string // channelIDs + failureCalls []failureCall + recordErr error +} + +type failureCall struct { + channelID string + err error +} + +func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string) error { + m.successCalls = append(m.successCalls, channelID) + + return m.recordErr +} + +func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, err error) error { + m.failureCalls = append(m.failureCalls, failureCall{channelID, err}) + + return m.recordErr +} + +type mockTemplateRenderer struct { + renderSubject string + renderBody string + renderErr error + renderInput workertypes.EmailDeliveryJob +} + +func (m *mockTemplateRenderer) RenderDigest(job workertypes.EmailDeliveryJob) (string, string, error) { + m.renderInput = job + + return m.renderSubject, m.renderBody, m.renderErr +} + +func testGeneratedAt() time.Time { + return time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) +} + +const testChannelID = "chan-1" + +func testMetadata() workertypes.DeliveryMetadata { + return workertypes.DeliveryMetadata{ + EventID: "event-1", + SearchID: "search-1", + Query: "query-string", + Frequency: workertypes.FrequencyMonthly, + GeneratedAt: testGeneratedAt(), + } +} + +// --- Tests --- + +func TestProcessMessage_Success(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sender := new(mockEmailSender) + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderSubject = "Subject" + renderer.renderBody = "Body" + + h := NewSender(sender, stateManager, renderer) + + err := h.ProcessMessage(ctx, job) + if err != nil { + t.Fatalf("ProcessMessage failed: %v", err) + } + + // Verify Renderer Input + if diff := cmp.Diff(job, renderer.renderInput); diff != "" { + t.Errorf("Renderer input mismatch (-want +got):\n%s", diff) + } + + // Verify Send + if len(sender.sentCalls) != 1 { + t.Fatalf("Expected 1 email sent, got %d", len(sender.sentCalls)) + } + if sender.sentCalls[0].to != "user@example.com" { + t.Errorf("Recipient mismatch: %s", sender.sentCalls[0].to) + } + + // Verify State + if len(stateManager.successCalls) != 1 { + t.Errorf("Expected 1 success record, got %d", len(stateManager.successCalls)) + } + if stateManager.successCalls[0] != testChannelID { + t.Errorf("Success recorded for wrong channel: %s", stateManager.successCalls[0]) + } +} + +func TestProcessMessage_RenderError(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sender := new(mockEmailSender) + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderErr = errors.New("template error") + + h := NewSender(sender, stateManager, renderer) + + // Should return nil (ACK) for rendering error (assuming permanent for now) + if err := h.ProcessMessage(ctx, job); err != nil { + t.Errorf("Expected nil error for render failure, got %v", err) + } + + // Should record failure + if len(stateManager.failureCalls) != 1 { + t.Fatal("Expected failure recording") + } + + // Should NOT send + if len(sender.sentCalls) > 0 { + t.Error("Should not send email on render error") + } +} + +func TestProcessMessage_SendError(t *testing.T) { + ctx := context.Background() + job := workertypes.EmailDeliveryJob{ + SubscriptionID: "sub-1", + Metadata: testMetadata(), + RecipientEmail: "user@example.com", + SummaryRaw: []byte("{}"), + ChannelID: "chan-1", + } + + sendErr := errors.New("smtp timeout") + sender := &mockEmailSender{sendErr: sendErr, sentCalls: nil} + stateManager := new(mockChannelStateManager) + renderer := new(mockTemplateRenderer) + renderer.renderSubject = "S" + renderer.renderBody = "B" + + h := NewSender(sender, stateManager, renderer) + + // Should return error (NACK) for send failure to allow retry + err := h.ProcessMessage(ctx, job) + if !errors.Is(err, sendErr) { + t.Errorf("Expected send error to propagate, got %v", err) + } + + // Should record failure in DB as well + if len(stateManager.failureCalls) != 1 { + t.Fatal("Expected failure recording") + } + if stateManager.failureCalls[0].channelID != testChannelID { + t.Errorf("Recorded failure for wrong channel") + } +} diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher.go b/workers/push_delivery/pkg/dispatcher/dispatcher.go index f7ac6f64e..cc97cf12a 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher.go @@ -153,6 +153,7 @@ func (g *deliveryJobGenerator) VisitV1(s workertypes.EventSummary) error { RecipientEmail: sub.EmailAddress, SummaryRaw: g.rawSummary, Metadata: deliveryMetadata, + ChannelID: sub.ChannelID, }) } diff --git a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go index 5f847f052..7d1fe8853 100644 --- a/workers/push_delivery/pkg/dispatcher/dispatcher_test.go +++ b/workers/push_delivery/pkg/dispatcher/dispatcher_test.go @@ -137,6 +137,7 @@ func TestProcessEvent_Success(t *testing.T) { UserID: "user-1", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, // Matches EmailAddress: "user1@example.com", + ChannelID: "chan-1", }, { SubscriptionID: "sub-2", @@ -144,6 +145,7 @@ func TestProcessEvent_Success(t *testing.T) { // Does not match (summary is Newly) Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToWidely}, EmailAddress: "user2@example.com", + ChannelID: "chan-2", }, }, } @@ -210,6 +212,7 @@ func TestProcessEvent_Success(t *testing.T) { Frequency: frequency, GeneratedAt: generatedAt, }, + ChannelID: "chan-1", } if diff := cmp.Diff(expectedJob, job); diff != "" { @@ -241,6 +244,7 @@ func TestProcessEvent_NoChanges_FiltersAll(t *testing.T) { UserID: "user-1", Triggers: []workertypes.JobTrigger{"any_change"}, EmailAddress: "user1@example.com", + ChannelID: "chan-1", }, }, } @@ -323,9 +327,9 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, - UserID: "u1", EmailAddress: "e1"}, + UserID: "u1", EmailAddress: "e1", ChannelID: "chan-1"}, {SubscriptionID: "sub-2", Triggers: []workertypes.JobTrigger{workertypes.FeaturePromotedToNewly}, - UserID: "u2", EmailAddress: "e2"}, + UserID: "u2", EmailAddress: "e2", ChannelID: "chan-2"}, }, } @@ -371,6 +375,9 @@ func TestProcessEvent_PublisherPartialFailure(t *testing.T) { if publisher.emailJobs[0].SubscriptionID != "sub-2" { t.Errorf("Expected sub-2 to succeed, got %s", publisher.emailJobs[0].SubscriptionID) } + if publisher.emailJobs[0].ChannelID != "chan-2" { + t.Errorf("Expected chan-2 to succeed, got %s", publisher.emailJobs[0].ChannelID) + } assertFindSubscribersCalledWith(t, finder, generic.ValuePtr(emptyFinderReq())) } @@ -378,7 +385,8 @@ func TestProcessEvent_JobCount(t *testing.T) { // Verify that if no jobs are generated (e.g. no matching triggers), ProcessEvent returns early/cleanly. subSet := &workertypes.SubscriberSet{ Emails: []workertypes.EmailSubscriber{ - {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{}, EmailAddress: "e1", UserID: "u1"}, // No match + {SubscriptionID: "sub-1", Triggers: []workertypes.JobTrigger{}, EmailAddress: "e1", UserID: "u1", + ChannelID: "chan-1"}, // No match }, } finder := &mockSubscriptionFinder{