ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Enable mail opt-in on first favorite; add one‑click unsubscribe and backfill cmd (#479)

* Enable mail opt-in on first favorite store and add unsubscribe link

* yikes so much to get token from config

* think we have it

* okay only show if signed in

* move head guard add to plain text

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
c7c3a54d 4de716fc

+267 -52
+1 -1
cmd/careme/web.go
··· 92 92 return fmt.Errorf("failed to create location server: %w", err) 93 93 } 94 94 95 - userHandler := users.NewHandler(userStorage, locationStorage, authClient) 95 + userHandler := users.NewHandler(userStorage, locationStorage, authClient, users.NewUnsubscribeTokenFactory(*cfg)) 96 96 userHandler.Register(appRoutes) 97 97 98 98 locationServer := locations.NewServer(locationStorage, centroids, userStorage)
+2 -1
cmd/careme/web_e2e_test.go
··· 178 178 infraRoutes := routing.Wrap(rootMux, BaseMiddleware) 179 179 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 180 180 locationServer.Register(appRoutes, mockAuth) 181 - users.NewHandler(userStorage, locationStorage, mockAuth).Register(appRoutes) 181 + utfactory := users.FakeUnsubscribeTokenFactory() 182 + users.NewHandler(userStorage, locationStorage, mockAuth, utfactory).Register(appRoutes) 182 183 recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, cacheStore, mockAuth).Register(appRoutes) 183 184 184 185 ro := &readyOnce{}
+24 -16
internal/mail/mail.go
··· 11 11 "fmt" 12 12 "log/slog" 13 13 "net/http" 14 + "net/url" 14 15 "os" 15 16 "time" 16 17 ··· 51 52 } 52 53 53 54 type mailer struct { 54 - cache cache.Cache 55 - userStorage *users.Storage 56 - generator generator // interface requires making params public 57 - locServer locServer 58 - client emailClient 59 - publicOrigin string 60 - wait func() 55 + cache cache.Cache 56 + userStorage *users.Storage 57 + generator generator // interface requires making params public 58 + locServer locServer 59 + client emailClient 60 + publicOrigin string 61 + wait func() 62 + unsubscribeFactory users.UnsubscribeTokenFactory 61 63 } 62 64 63 65 // TODO share some of this with web.go? good for mocking? ··· 92 94 } 93 95 94 96 return &mailer{ 95 - cache: cache, 96 - userStorage: userStorage, 97 - generator: generator, 98 - locServer: locationserver, 99 - client: sendgrid.NewSendClient(sendgridkey), 100 - publicOrigin: cfg.ResolvedPublicOrigin(), 101 - wait: mc.Wait, 97 + cache: cache, 98 + userStorage: userStorage, 99 + generator: generator, 100 + locServer: locationserver, 101 + client: sendgrid.NewSendClient(sendgridkey), 102 + publicOrigin: cfg.ResolvedPublicOrigin(), 103 + wait: mc.Wait, 104 + unsubscribeFactory: users.NewUnsubscribeTokenFactory(*cfg), 102 105 }, nil 103 106 } 104 107 ··· 207 210 } 208 211 209 212 var buf bytes.Buffer 210 - if err := recipes.FormatMail(p, *shoppingList, m.publicOrigin, &buf); err != nil { 213 + unsubscribeURL := m.publicOrigin + "/user/unsubscribe?" + url.Values{ 214 + "user": []string{user.ID}, 215 + "token": []string{m.unsubscribeFactory.UnsubscribeToken(user.ID)}, 216 + }.Encode() 217 + if err := recipes.FormatMail(p, *shoppingList, m.publicOrigin, unsubscribeURL, &buf); err != nil { 211 218 slog.ErrorContext(ctx, "failed to format mail", "error", err) 212 219 return 213 220 } ··· 215 222 from := mail.NewEmail("Chef", "chef@careme.cooking") 216 223 subject := "Your new recipes are ready!" 217 224 218 - plainTextContent := "Check out your new recipes at " + m.publicOrigin + "/recipes?h=" + paramsHash 225 + plainTextContent := "Check out your new recipes at " + m.publicOrigin + "/recipes?h=" + paramsHash + 226 + "\n\n Unsubscribe from these emails: " + unsubscribeURL 219 227 220 228 to := mail.NewEmail(user.Email[0], user.Email[0]) 221 229 message := mail.NewSingleEmail(from, subject, to, plainTextContent, buf.String())
+14 -4
internal/mail/mail_test.go
··· 15 15 "careme/internal/locations" 16 16 "careme/internal/recipes" 17 17 "careme/internal/templates" 18 + "careme/internal/users" 18 19 utypes "careme/internal/users/types" 19 20 20 21 "github.com/sendgrid/rest" ··· 94 95 type fakeMailClient struct { 95 96 response *rest.Response 96 97 err error 98 + last *sgmail.SGMailV3 97 99 } 98 100 99 - func (f *fakeMailClient) Send(_ *sgmail.SGMailV3) (*rest.Response, error) { 101 + func (f *fakeMailClient) Send(msg *sgmail.SGMailV3) (*rest.Response, error) { 102 + f.last = msg 100 103 return f.response, f.err 101 104 } 102 105 ··· 120 123 client: &fakeMailClient{ 121 124 response: &rest.Response{StatusCode: 500, Body: "sendgrid internal error"}, 122 125 }, 126 + unsubscribeFactory: users.FakeUnsubscribeTokenFactory(), 123 127 } 124 128 125 129 m.sendEmail(context.Background(), utypes.User{ ··· 140 144 func TestSendEmail_RecordsSentClaimOnSuccessSendGridStatus(t *testing.T) { 141 145 fc := newFakeMailCache(t) 142 146 location := &locations.Location{ID: "123", Name: "Test Store", Address: "123 Test St", ZipCode: "98005"} 147 + client := &fakeMailClient{ 148 + response: &rest.Response{StatusCode: 202, Body: "accepted"}, 149 + } 143 150 m := &mailer{ 144 151 cache: fc, 145 152 locServer: &fakeMailLocServer{ 146 153 location: location, 147 154 }, 148 - client: &fakeMailClient{ 149 - response: &rest.Response{StatusCode: 202, Body: "accepted"}, 150 - }, 155 + client: client, 156 + publicOrigin: "https://careme.cooking", 157 + unsubscribeFactory: users.FakeUnsubscribeTokenFactory(), 151 158 } 152 159 153 160 m.sendEmail(context.Background(), utypes.User{ ··· 185 192 } 186 193 if claim.ParamsHash == "" { 187 194 t.Fatalf("expected claim params hash to be set") 195 + } 196 + if client.last == nil || !strings.Contains(client.last.Content[1].Value, "Unsubscribe") { 197 + t.Fatalf("expected sent message to contain unsubscribe link %s", client.last.Content[1].Value) 188 198 } 189 199 }
+15 -13
internal/recipes/html.go
··· 296 296 } 297 297 298 298 // drops clarity, instructions and most of shoppinglist 299 - func FormatMail(p *generatorParams, l ai.ShoppingList, publicOrigin string, writer io.Writer) error { 299 + func FormatMail(p *generatorParams, l ai.ShoppingList, publicOrigin string, unsubscribeURL string, writer io.Writer) error { 300 300 data := struct { 301 - Location locations.Location 302 - Date string 303 - Hash string 304 - Recipes []ai.Recipe 305 - Domain string 306 - Style seasons.Style 301 + Location locations.Location 302 + Date string 303 + Hash string 304 + Recipes []ai.Recipe 305 + Domain string 306 + UnsubscribeURL string 307 + Style seasons.Style 307 308 }{ 308 - Location: *p.Location, 309 - Date: p.Date.Format("2006-01-02"), 310 - Hash: p.Hash(), 311 - Recipes: l.Recipes, 312 - Domain: publicOrigin, 313 - Style: seasons.GetCurrentStyle(), 309 + Location: *p.Location, 310 + Date: p.Date.Format("2006-01-02"), 311 + Hash: p.Hash(), 312 + Recipes: l.Recipes, 313 + Domain: publicOrigin, 314 + UnsubscribeURL: unsubscribeURL, 315 + Style: seasons.GetCurrentStyle(), 314 316 } 315 317 316 318 return templates.Mail.Execute(writer, data)
+15 -7
internal/recipes/server.go
··· 923 923 http.Error(w, "failed to load recipe parameters", http.StatusInternalServerError) 924 924 return 925 925 } 926 - if r.URL.Query().Get("mail") == "true" { 927 - if err := FormatMail(p, *slist, s.cfg.ResolvedPublicOrigin(), w); err != nil { 928 - slog.ErrorContext(ctx, "failed to render mail template", "error", err) 929 - http.Error(w, "failed to render mail template", http.StatusInternalServerError) 930 - } 931 - return 932 - } 933 926 userID, err := s.clerk.GetUserIDFromRequest(r) 934 927 signedIn := !errors.Is(err, auth.ErrNoSession) 935 928 if signedIn { ··· 940 933 return 941 934 } 942 935 s.mergeParamsWithSelection(ctx, p, fromStore, slist.Recipes) 936 + } 937 + if r.URL.Query().Get("mail") == "true" { 938 + tf := users.NewUnsubscribeTokenFactory(*s.cfg) 939 + var unsubscribeURL string 940 + if signedIn { 941 + unsubscribeURL = s.cfg.ResolvedPublicOrigin() + "/user/unsubscribe?" + url.Values{ 942 + "user": []string{userID}, 943 + "token": []string{tf.UnsubscribeToken(userID)}, 944 + }.Encode() 945 + } 946 + if err := FormatMail(p, *slist, s.cfg.ResolvedPublicOrigin(), unsubscribeURL, w); err != nil { 947 + slog.ErrorContext(ctx, "failed to render mail template", "error", err) 948 + http.Error(w, "failed to render mail template", http.StatusInternalServerError) 949 + } 950 + return 943 951 } 944 952 applySavedToRecipes(slist.Recipes, p) 945 953 wineRecommendations := make(map[string]*ai.WineSelection, len(slist.Recipes))
+5
internal/templates/mail.html
··· 49 49 <p style="margin:16px 0 0 0; text-align:center; font-size:12px; color:#6b7280;"> 50 50 Planned by Careme. 51 51 </p> 52 + {{if .UnsubscribeURL}} 53 + <p style="margin:8px 0 0 0; text-align:center; font-size:12px; color:#6b7280;"> 54 + <a href="{{.UnsubscribeURL}}" style="color:#6b7280; text-decoration:underline;">Unsubscribe</a> 55 + </p> 56 + {{end}} 52 57 </section> 53 58 </main> 54 59 </body>
+66 -9
internal/users/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/subtle" 5 6 "encoding/json" 6 7 "errors" 7 8 "html/template" ··· 29 30 } 30 31 31 32 type server struct { 32 - storage *Storage 33 - userTmpl *template.Template // just remove or is this useful? 34 - locGetter locationGetter 35 - clerk auth.AuthClient // make an interface 33 + storage *Storage 34 + userTmpl *template.Template // just remove or is this useful? 35 + locGetter locationGetter 36 + clerk auth.AuthClient // make an interface 37 + unsubscribeFactory UnsubscribeTokenFactory 36 38 } 37 39 38 40 type pastRecipeView struct { ··· 48 50 ) 49 51 50 52 // NewHandler returns an http.Handler that serves the user related routes under /user. 51 - func NewHandler(storage *Storage, locGetter locationGetter, clerkClient auth.AuthClient) *server { 53 + func NewHandler(storage *Storage, locGetter locationGetter, clerkClient auth.AuthClient, unsubscribe UnsubscribeTokenFactory) *server { 52 54 return &server{ 53 - storage: storage, 54 - userTmpl: templates.User, 55 - locGetter: locGetter, 56 - clerk: clerkClient, 55 + storage: storage, 56 + userTmpl: templates.User, 57 + locGetter: locGetter, 58 + clerk: clerkClient, 59 + unsubscribeFactory: unsubscribe, 57 60 } 58 61 } 59 62 ··· 61 64 mux.HandleFunc("/user", s.handleUser) 62 65 mux.HandleFunc("POST /user/recipes/remove", s.handleRemoveUserRecipe) 63 66 mux.HandleFunc("POST /user/favorite", s.handleFavorite) 67 + mux.HandleFunc("GET /user/unsubscribe", s.handleUnsubscribe) 64 68 mux.HandleFunc("GET /user/exists", s.handleExists) 65 69 } 66 70 ··· 164 168 return 165 169 } 166 170 171 + favoriteBefore := strings.TrimSpace(currentUser.FavoriteStore) != "" 172 + 167 173 // Only update favorite_store if provided 168 174 if favoriteStore := strings.TrimSpace(r.FormValue("favorite_store")); favoriteStore != "" || r.Form.Has("favorite_store") { 169 175 currentUser.FavoriteStore = favoriteStore ··· 178 184 currentUser.Directive = generationPrompt 179 185 } 180 186 currentUser.MailOptIn = r.FormValue("mail_opt_in") == "1" 187 + if !favoriteBefore && strings.TrimSpace(currentUser.FavoriteStore) != "" { 188 + currentUser.MailOptIn = true 189 + } 181 190 182 191 if err := s.storage.Update(currentUser); err != nil { 183 192 slog.ErrorContext(ctx, "failed to update user", "error", err) ··· 321 330 http.Error(w, "missing favorite_store", http.StatusBadRequest) 322 331 return 323 332 } 333 + favoriteBefore := strings.TrimSpace(currentUser.FavoriteStore) != "" 324 334 currentUser.FavoriteStore = favoriteStore 335 + if !favoriteBefore && favoriteStore != "" { 336 + currentUser.MailOptIn = true 337 + } 325 338 if err := s.storage.Update(currentUser); err != nil { 326 339 slog.ErrorContext(ctx, "failed to update user", "error", err) 327 340 http.Error(w, "unable to save preferences", http.StatusInternalServerError) ··· 329 342 } 330 343 w.Header().Set("HX-Refresh", "true") 331 344 w.WriteHeader(http.StatusNoContent) 345 + } 346 + 347 + func (s *server) handleUnsubscribe(w http.ResponseWriter, r *http.Request) { 348 + ctx := r.Context() 349 + if err := r.ParseForm(); err != nil { 350 + http.Error(w, "invalid unsubscribe link", http.StatusBadRequest) 351 + return 352 + } 353 + 354 + // keep scrapers from unsubscribing people 355 + if r.Method == http.MethodHead { 356 + w.WriteHeader(http.StatusOK) 357 + return 358 + } 359 + 360 + userID := strings.TrimSpace(r.FormValue("user")) 361 + token := strings.TrimSpace(r.FormValue("token")) 362 + if userID == "" || token == "" { 363 + http.Error(w, "invalid unsubscribe link", http.StatusBadRequest) 364 + return 365 + } 366 + currentUser, err := s.storage.GetByID(userID) 367 + if err != nil { 368 + if errors.Is(err, ErrNotFound) { 369 + http.Error(w, "invalid unsubscribe link", http.StatusBadRequest) 370 + return 371 + } 372 + slog.ErrorContext(ctx, "failed to load user for unsubscribe", "user_id", userID, "error", err) 373 + http.Error(w, "unable to process request", http.StatusInternalServerError) 374 + return 375 + } 376 + want := s.unsubscribeFactory.UnsubscribeToken(currentUser.ID) 377 + if subtle.ConstantTimeCompare([]byte(token), []byte(want)) != 1 { 378 + http.Error(w, "invalid unsubscribe link", http.StatusBadRequest) 379 + return 380 + } 381 + currentUser.MailOptIn = false 382 + if err := s.storage.Update(currentUser); err != nil { 383 + slog.ErrorContext(ctx, "failed to disable mail opt in", "user_id", userID, "error", err) 384 + http.Error(w, "unable to process request", http.StatusInternalServerError) 385 + return 386 + } 387 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 388 + _, _ = w.Write([]byte("You are unsubscribed from Careme recipe emails.")) 332 389 } 333 390 334 391 func isHTMXRequest(r *http.Request) bool {
+2 -1
internal/users/server_e2e_test.go
··· 26 26 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 27 27 storage := NewStorage(cacheStore) 28 28 29 - srv := NewHandler(storage, nil, auth.DefaultMock()) 29 + tf := FakeUnsubscribeTokenFactory() 30 + srv := NewHandler(storage, nil, auth.DefaultMock(), tf) 30 31 mux := http.NewServeMux() 31 32 srv.Register(mux) 32 33
+3
internal/users/server_favorite_test.go
··· 67 67 if user.FavoriteStore != "222" { 68 68 t.Fatalf("expected favorite store to be 222, got %q", user.FavoriteStore) 69 69 } 70 + if !user.MailOptIn { 71 + t.Fatal("expected mail opt in to be enabled after first favorite store set") 72 + } 70 73 } 71 74 72 75 func TestHandleFavoriteRejectsNonHTMXRequest(t *testing.T) {
+34
internal/users/unsubscribe.go
··· 1 + package users 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + 8 + "careme/internal/config" 9 + ) 10 + 11 + type unsubscribeTokenFactory struct { 12 + secret []byte 13 + } 14 + 15 + type UnsubscribeTokenFactory interface { 16 + UnsubscribeToken(userid string) string 17 + } 18 + 19 + func NewUnsubscribeTokenFactory(cfg config.Config) *unsubscribeTokenFactory { 20 + secret := cfg.Clerk.SecretKey // what else can we use 21 + return &unsubscribeTokenFactory{secret: []byte(secret)} 22 + } 23 + 24 + func (f *unsubscribeTokenFactory) UnsubscribeToken(userid string) string { 25 + // Why not just do SHA256(key || message)? Because plain hash functions like SHA-256 have structural properties that make naive keyed constructions risky, especially length-extension attacks for Merkle–Damgård hashes like SHA-256. 26 + mac := hmac.New(sha256.New, f.secret) 27 + mac.Write([]byte(userid)) 28 + mac.Write([]byte("|")) 29 + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) 30 + } 31 + 32 + func FakeUnsubscribeTokenFactory() *unsubscribeTokenFactory { 33 + return &unsubscribeTokenFactory{secret: []byte("fake_secret_for_testing")} 34 + }
+86
internal/users/unsubscribe_test.go
··· 1 + package users 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "net/url" 7 + "path/filepath" 8 + "testing" 9 + "time" 10 + 11 + "careme/internal/auth" 12 + "careme/internal/cache" 13 + utypes "careme/internal/users/types" 14 + ) 15 + 16 + func TestHandleUnsubscribeDisablesMailOptInOnGet(t *testing.T) { 17 + t.Parallel() 18 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 19 + tf := FakeUnsubscribeTokenFactory() 20 + s := NewHandler(NewStorage(cacheStore), nil, auth.DefaultMock(), tf) 21 + u := &utypes.User{ 22 + ID: "u-1", 23 + Email: []string{"u1@example.com"}, 24 + FavoriteStore: "111", 25 + MailOptIn: true, 26 + ShoppingDay: time.Saturday.String(), 27 + } 28 + if err := s.storage.Update(u); err != nil { 29 + t.Fatalf("failed to seed user: %v", err) 30 + } 31 + 32 + params := url.Values{ 33 + "user": []string{u.ID}, 34 + "token": []string{tf.UnsubscribeToken(u.ID)}, 35 + } 36 + req := httptest.NewRequest(http.MethodGet, "/user/unsubscribe?"+params.Encode(), nil) 37 + rr := httptest.NewRecorder() 38 + 39 + s.handleUnsubscribe(rr, req) 40 + 41 + if rr.Code != http.StatusOK { 42 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 43 + } 44 + updated, err := s.storage.GetByID(u.ID) 45 + if err != nil { 46 + t.Fatalf("failed to load updated user: %v", err) 47 + } 48 + if updated.MailOptIn { 49 + t.Fatal("expected mail opt in to be disabled") 50 + } 51 + } 52 + 53 + func TestHandleUnsubscribeDoesNotDisableMailOptInOnHead(t *testing.T) { 54 + t.Parallel() 55 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 56 + tf := FakeUnsubscribeTokenFactory() 57 + s := NewHandler(NewStorage(cacheStore), nil, auth.DefaultMock(), tf) 58 + u := &utypes.User{ 59 + ID: "u-1", 60 + Email: []string{"u1@example.com"}, 61 + FavoriteStore: "111", 62 + MailOptIn: true, 63 + ShoppingDay: time.Saturday.String(), 64 + } 65 + if err := s.storage.Update(u); err != nil { 66 + t.Fatalf("failed to seed user: %v", err) 67 + } 68 + req := httptest.NewRequest(http.MethodHead, "/user/unsubscribe?"+url.Values{ 69 + "user": []string{u.ID}, 70 + "token": []string{tf.UnsubscribeToken(u.ID)}, 71 + }.Encode(), nil) 72 + rr := httptest.NewRecorder() 73 + 74 + s.handleUnsubscribe(rr, req) 75 + 76 + if rr.Code != http.StatusOK { 77 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 78 + } 79 + updated, err := s.storage.GetByID(u.ID) 80 + if err != nil { 81 + t.Fatalf("failed to load updated user: %v", err) 82 + } 83 + if !updated.MailOptIn { 84 + t.Fatal("expected HEAD unsubscribe request to leave mail opt in enabled") 85 + } 86 + }