ai cooking
0
fork

Configure Feed

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

Give the user a permanent instruction box. (#257)

* initial codex

* clean this up a little.

* get some tests in there

* back off some silly queries

* simplify tests slightly

* directive

* merge outside of params

* remove unused

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
4b1107dd cb40e224

+410 -45
+8 -8
internal/mail/mail_test.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "io" 7 + "os" 7 8 "strings" 8 9 "testing" 9 10 "time" ··· 19 20 "github.com/sendgrid/rest" 20 21 sgmail "github.com/sendgrid/sendgrid-go/helpers/mail" 21 22 ) 23 + 24 + func TestMain(m *testing.M) { 25 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 26 + panic(err) 27 + } 28 + os.Exit(m.Run()) 29 + } 22 30 23 31 type fakeMailCache struct { 24 32 shoppingListJSON string ··· 94 102 } 95 103 96 104 func TestSendEmail_DoesNotRecordSentClaimOnNonSuccessSendGridStatus(t *testing.T) { 97 - if err := templates.Init(&config.Config{}, "/assets/tailwind.css"); err != nil { 98 - t.Fatalf("failed to initialize templates: %v", err) 99 - } 100 105 101 106 fc := newFakeMailCache(t) 102 107 location := &locations.Location{ID: "123", Name: "Test Store", Address: "123 Test St", ZipCode: "98005"} ··· 126 131 } 127 132 128 133 func TestSendEmail_RecordsSentClaimOnSuccessSendGridStatus(t *testing.T) { 129 - if err := templates.Init(&config.Config{}, "/assets/tailwind.css"); err != nil { 130 - t.Fatalf("failed to initialize templates: %v", err) 131 - } 132 134 133 135 fc := newFakeMailCache(t) 134 136 location := &locations.Location{ID: "123", Name: "Test Store", Address: "123 Test St", ZipCode: "98005"} ··· 179 181 t.Fatalf("expected claim params hash to be set") 180 182 } 181 183 } 182 - 183 - //TODO tests for optin and day of week?
+17 -1
internal/recipes/generator.go
··· 90 90 if err != nil { 91 91 return nil, fmt.Errorf("failed to get staples: %w", err) 92 92 } 93 - shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, p.Instructions, p.Date, p.LastRecipes) 93 + 94 + instructions := mergeInstructions(p.Directive, p.Instructions) 95 + 96 + shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, instructions, p.Date, p.LastRecipes) 94 97 if err != nil { 95 98 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) 96 99 } ··· 103 106 p.ConversationID = shoppingList.ConversationID 104 107 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 105 108 return shoppingList, nil 109 + } 110 + 111 + func mergeInstructions(directive string, instructions string) string { 112 + directive = strings.TrimSpace(directive) 113 + instructions = strings.TrimSpace(instructions) 114 + 115 + if directive == "" { 116 + return instructions 117 + } 118 + if instructions == "" { 119 + return directive 120 + } 121 + return directive + "\n\n" + instructions 106 122 } 107 123 108 124 func (g *Generator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) {
+4 -1
internal/recipes/params.go
··· 32 32 Date time.Time `json:"date,omitempty"` 33 33 Staples []filter `json:"staples,omitempty"` 34 34 // People int 35 + //per round instuctions 35 36 Instructions string `json:"instructions,omitempty"` 37 + Directive string `json:"directive,omitempty"` // this is the new one that will be used. Can remove GenerationPrompt after a while. 36 38 LastRecipes []string `json:"last_recipes,omitempty"` 37 39 // UserID string `json:"user_id,omitempty"` 38 40 ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? ··· 61 63 fnv := fnv.New64a() 62 64 lo.Must(io.WriteString(fnv, g.Location.ID)) 63 65 lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 64 - bytes := lo.Must(json.Marshal(g.Staples)) 66 + bytes := lo.Must(json.Marshal(g.Staples)) //should we remove this so this is stable when we change staples? 65 67 lo.Must(fnv.Write(bytes)) 66 68 lo.Must(io.WriteString(fnv, g.Instructions)) // rethink this? if they're all in convo should we have one id and ability to walk back? 69 + lo.Must(io.WriteString(fnv, g.Directive)) 67 70 for _, saved := range g.Saved { 68 71 lo.Must(io.WriteString(fnv, "saved"+saved.ComputeHash())) 69 72 }
+12 -12
internal/recipes/server.go
··· 377 377 // what do we do with this? 378 378 // p.UserID = currentUser.ID 379 379 380 + currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) // just for logging purposes in kickgeneration. We could do this in the generateion function instead to avoid the extra call on every not found. 381 + if err != nil { 382 + if !errors.Is(err, auth.ErrNoSession) { 383 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 384 + http.Error(w, "unable to load account", http.StatusInternalServerError) 385 + return 386 + } 387 + slog.InfoContext(ctx, "failed got no sesion from request", "error", err, "url", r.URL.String()) 388 + http.Redirect(w, r, "/", http.StatusSeeOther) 389 + return 390 + } 391 + 380 392 //if params are already saved redirect and assume someone kicks off genration 381 393 382 394 if err := s.SaveParams(ctx, p); err != nil { ··· 391 403 } 392 404 393 405 hash := p.Hash() 394 - 395 - currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) // just for logging purposes in kickgeneration. We could do this in the generateion function instead to avoid the extra call on every not found. 396 - if err != nil { 397 - if !errors.Is(err, auth.ErrNoSession) { 398 - slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 399 - http.Error(w, "unable to load account", http.StatusInternalServerError) 400 - return 401 - } 402 - slog.InfoContext(ctx, "failed got no sesion from request", "error", err, "url", r.URL.String()) 403 - http.Redirect(w, r, "/", http.StatusSeeOther) 404 - return 405 - } 406 406 407 407 // Handle finalize - save recipes to user profile and display filtered list 408 408 if r.URL.Query().Get("finalize") == "true" {
+31
internal/recipes/server_prompt_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestMergeInstructions(t *testing.T) { 8 + t.Run("profile only", func(t *testing.T) { 9 + got := mergeInstructions("Always include one vegetarian meal.", "") 10 + want := "Always include one vegetarian meal." 11 + if got != want { 12 + t.Fatalf("expected %q, got %q", want, got) 13 + } 14 + }) 15 + 16 + t.Run("request only", func(t *testing.T) { 17 + got := mergeInstructions("", "No shellfish") 18 + want := "No shellfish" 19 + if got != want { 20 + t.Fatalf("expected %q, got %q", want, got) 21 + } 22 + }) 23 + 24 + t.Run("profile and request", func(t *testing.T) { 25 + got := mergeInstructions("Always include one vegetarian meal.", "No shellfish") 26 + want := "Always include one vegetarian meal.\n\nNo shellfish" 27 + if got != want { 28 + t.Fatalf("expected %q, got %q", want, got) 29 + } 30 + }) 31 + }
+37 -23
internal/templates/user.html
··· 40 40 41 41 {{if ne .ActiveTab "past"}} 42 42 <section class="space-y-4"> 43 - <h2 class="text-lg font-semibold text-brand-700">Account Information</h2> 44 - <div class="grid gap-3 sm:grid-cols-2"> 45 - <div> 46 - <h3 class="text-sm font-medium text-gray-600">Email Addresses</h3> 47 - <ul class="mt-2 space-y-1 text-sm text-gray-700"> 48 - {{range .User.Email}} 49 - <li class="rounded-lg bg-brand-50 px-3 py-2 text-brand-700">{{.}}</li> 50 - {{end}} 51 - </ul> 52 - </div> 53 - <div> 54 - <h3 class="text-sm font-medium text-gray-600">Member Since</h3> 55 - <p class="mt-2 text-sm text-gray-700">{{.User.CreatedAt.Format "January 2, 2006"}}</p> 56 - </div> 57 - </div> 58 - </section> 59 - 60 - <section class="space-y-4"> 61 43 <h2 class="text-lg font-semibold text-brand-700">Preferences</h2> 62 44 <form method="POST" action="/user" class="space-y-6"> 63 45 <div class="space-y-2"> ··· 95 77 </select> 96 78 </div> 97 79 98 - <div class="space-y-2"> 99 - <label for="mail_opt_in" class="text-sm font-medium text-gray-700">Recipe emails</label> 80 + <div> 100 81 <label class="inline-flex items-center gap-2 text-sm text-gray-700"> 101 82 <input id="mail_opt_in" 102 83 name="mail_opt_in" ··· 104 85 value="1" 105 86 {{if .User.MailOptIn}}checked{{end}} 106 87 class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-400" /> 107 - Send me recipe emails 88 + Email me recipes on my shopping day 108 89 </label> 109 - <p class="text-xs text-gray-500">Turn this on to receive recipe emails from your saved store preferences.</p> 90 + </div> 91 + 92 + <div class="space-y-2"> 93 + <label for="directive" class="text-sm font-medium text-gray-700">Chef’s directive</label> 94 + <textarea id="directive" 95 + name="directive" 96 + rows="8" 97 + placeholder="Example: Generate 5 recipes for 4 people, avoid shellfish, prioritize high-protein meals, and keep prep under 30 minutes." 98 + class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400">{{.User.Directive}}</textarea> 99 + <p class="text-xs text-gray-500">This is injected directly into recipe generation. Keep it free form and edit anytime.</p> 100 + <ul class="list-disc space-y-1 pl-5 text-xs text-gray-500"> 101 + <li>Override defaults: "Generate 5 recipes for 4 people."</li> 102 + <li>Diet focus: "Low-carb, high-protein, no pork or shellfish."</li> 103 + <li>Style: "One vegetarian meal and one soup each week."</li> 104 + <li>Time/equipment: "Weeknight meals under 30 minutes, no slow cooker."</li> 105 + </ul> 110 106 </div> 111 107 112 108 <button type="submit" ··· 115 111 </button> 116 112 </form> 117 113 </section> 114 + 115 + <section class="space-y-4"> 116 + <h2 class="text-lg font-semibold text-brand-700">Account Information</h2> 117 + <div class="grid gap-3 sm:grid-cols-2"> 118 + <div> 119 + <h3 class="text-sm font-medium text-gray-600">Email Addresses</h3> 120 + <ul class="mt-2 space-y-1 text-sm text-gray-700"> 121 + {{range .User.Email}} 122 + <li class="rounded-lg bg-brand-50 px-3 py-2 text-brand-700">{{.}}</li> 123 + {{end}} 124 + </ul> 125 + </div> 126 + <div> 127 + <h3 class="text-sm font-medium text-gray-600">Member Since</h3> 128 + <p class="mt-2 text-sm text-gray-700">{{.User.CreatedAt.Format "January 2, 2006"}}</p> 129 + </div> 130 + </div> 131 + </section> 118 132 {{end}} 119 133 120 134 {{if eq .ActiveTab "past"}} 121 135 <section class="space-y-4"> 122 - 136 + 123 137 <form method="POST" action="/user/recipes" class="space-y-4 rounded-xl border border-brand-100 bg-brand-50/60 p-5"> 124 138 <div class="space-y-2"> 125 139 <label for="previous_recipe" class="text-sm font-medium text-gray-700">Add something you cooked to avoid repeats</label>
+4
internal/users/server.go
··· 135 135 if shoppingDay := strings.TrimSpace(r.FormValue("shopping_day")); shoppingDay != "" { 136 136 currentUser.ShoppingDay = shoppingDay 137 137 } 138 + if r.Form.Has("directive") { 139 + generationPrompt := strings.TrimSpace(r.FormValue("directive")) 140 + currentUser.Directive = generationPrompt 141 + } 138 142 currentUser.MailOptIn = r.FormValue("mail_opt_in") == "1" 139 143 140 144 if err := s.storage.Update(currentUser); err != nil {
+82
internal/users/server_e2e_test.go
··· 1 + package users 2 + 3 + import ( 4 + "careme/internal/auth" 5 + "careme/internal/cache" 6 + "careme/internal/config" 7 + "careme/internal/templates" 8 + "net/http" 9 + "net/http/httptest" 10 + "net/url" 11 + "os" 12 + "path/filepath" 13 + "strings" 14 + "testing" 15 + ) 16 + 17 + func TestMain(m *testing.M) { 18 + if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 19 + panic(err) 20 + } 21 + os.Exit(m.Run()) 22 + } 23 + 24 + func TestUserPageUpdate_E2E(t *testing.T) { 25 + 26 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 27 + storage := NewStorage(cacheStore) 28 + 29 + srv := NewHandler(storage, nil, auth.DefaultMock()) 30 + mux := http.NewServeMux() 31 + srv.Register(mux) 32 + 33 + getReq := httptest.NewRequest(http.MethodGet, "/user?tab=customize", nil) 34 + getResp := httptest.NewRecorder() 35 + mux.ServeHTTP(getResp, getReq) 36 + 37 + if getResp.Code != http.StatusOK { 38 + t.Fatalf("expected status %d for GET /user, got %d", http.StatusOK, getResp.Code) 39 + } 40 + if body := getResp.Body.String(); !strings.Contains(body, `name="directive"`) { 41 + t.Fatalf("expected generation prompt field on user page, got body: %s", body) 42 + } 43 + 44 + form := url.Values{ 45 + "favorite_store": {"70500874"}, 46 + "shopping_day": {"Monday"}, 47 + "mail_opt_in": {"1"}, 48 + "directive": {"Generate 4 recipes. No shellfish."}, 49 + } 50 + postReq := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(form.Encode())) 51 + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") 52 + postResp := httptest.NewRecorder() 53 + mux.ServeHTTP(postResp, postReq) 54 + 55 + if postResp.Code != http.StatusOK { 56 + t.Fatalf("expected status %d for POST /user, got %d", http.StatusOK, postResp.Code) 57 + } 58 + postBody := postResp.Body.String() 59 + if !strings.Contains(postBody, "Settings saved successfully!") { 60 + t.Fatalf("expected success message in response body, got body: %s", postBody) 61 + } 62 + if !strings.Contains(postBody, "Generate 4 recipes. No shellfish.") { 63 + t.Fatalf("expected prompt text in rendered page, got body: %s", postBody) 64 + } 65 + 66 + user, err := storage.GetByID("mock-clerk-user-id") 67 + if err != nil { 68 + t.Fatalf("expected saved user, got error: %v", err) 69 + } 70 + if got, want := user.FavoriteStore, "70500874"; got != want { 71 + t.Fatalf("expected favorite_store %q, got %q", want, got) 72 + } 73 + if got, want := user.ShoppingDay, "Monday"; got != want { 74 + t.Fatalf("expected shopping_day %q, got %q", want, got) 75 + } 76 + if !user.MailOptIn { 77 + t.Fatal("expected mail_opt_in to be true") 78 + } 79 + if got, want := user.Directive, "Generate 4 recipes. No shellfish."; got != want { 80 + t.Fatalf("expected directive %q, got %q", want, got) 81 + } 82 + }
+108
internal/users/server_favorite_test.go
··· 1 + package users 2 + 3 + import ( 4 + "careme/internal/auth" 5 + "careme/internal/cache" 6 + "context" 7 + "net/http" 8 + "net/http/httptest" 9 + "net/url" 10 + "path/filepath" 11 + "strings" 12 + "testing" 13 + ) 14 + 15 + type noSessionAuth struct{} 16 + 17 + func (n noSessionAuth) GetUserEmail(_ context.Context, _ string) (string, error) { 18 + return "", auth.ErrNoSession 19 + } 20 + 21 + func (n noSessionAuth) GetUserIDFromRequest(_ *http.Request) (string, error) { 22 + return "", auth.ErrNoSession 23 + } 24 + 25 + func (n noSessionAuth) WithAuthHTTP(handler http.Handler) http.Handler { 26 + return handler 27 + } 28 + 29 + func (n noSessionAuth) Register(_ *http.ServeMux) {} 30 + 31 + func newFavoriteTestServer(t *testing.T, clerk auth.AuthClient) *server { 32 + t.Helper() 33 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 34 + return &server{ 35 + storage: NewStorage(cacheStore), 36 + clerk: clerk, 37 + } 38 + } 39 + 40 + func TestHandleFavoriteHTMXRefreshesPage(t *testing.T) { 41 + t.Parallel() 42 + s := newFavoriteTestServer(t, auth.DefaultMock()) 43 + 44 + form := url.Values{ 45 + "favorite_store": {"222"}, 46 + } 47 + req := httptest.NewRequest(http.MethodPost, "/user/favorite", strings.NewReader(form.Encode())) 48 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 49 + req.Header.Set("HX-Request", "true") 50 + rr := httptest.NewRecorder() 51 + 52 + s.handleFavorite(rr, req) 53 + 54 + if rr.Code != http.StatusNoContent { 55 + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rr.Code) 56 + } 57 + if got := rr.Header().Get("HX-Refresh"); got != "true" { 58 + t.Fatalf("expected HX-Refresh true, got %q", got) 59 + } 60 + 61 + user, err := s.storage.GetByID("mock-clerk-user-id") 62 + if err != nil { 63 + t.Fatalf("expected user to be stored, got error %v", err) 64 + } 65 + if user.FavoriteStore != "222" { 66 + t.Fatalf("expected favorite store to be 222, got %q", user.FavoriteStore) 67 + } 68 + } 69 + 70 + func TestHandleFavoriteRejectsNonHTMXRequest(t *testing.T) { 71 + t.Parallel() 72 + s := newFavoriteTestServer(t, auth.DefaultMock()) 73 + 74 + form := url.Values{ 75 + "favorite_store": {"444"}, 76 + } 77 + req := httptest.NewRequest(http.MethodPost, "/user/favorite", strings.NewReader(form.Encode())) 78 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 79 + rr := httptest.NewRecorder() 80 + 81 + s.handleFavorite(rr, req) 82 + 83 + if rr.Code != http.StatusBadRequest { 84 + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code) 85 + } 86 + } 87 + 88 + func TestHandleFavoriteNoSessionHTMXSetsRedirectHeader(t *testing.T) { 89 + t.Parallel() 90 + s := newFavoriteTestServer(t, noSessionAuth{}) 91 + 92 + form := url.Values{ 93 + "favorite_store": {"555"}, 94 + } 95 + req := httptest.NewRequest(http.MethodPost, "/user/favorite", strings.NewReader(form.Encode())) 96 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 97 + req.Header.Set("HX-Request", "true") 98 + rr := httptest.NewRecorder() 99 + 100 + s.handleFavorite(rr, req) 101 + 102 + if rr.Code != http.StatusUnauthorized { 103 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 104 + } 105 + if got := rr.Header().Get("HX-Redirect"); got != "/" { 106 + t.Fatalf("expected HX-Redirect to /, got %q", got) 107 + } 108 + }
+106
internal/users/server_test.go
··· 1 + package users 2 + 3 + import ( 4 + "careme/internal/cache" 5 + utypes "careme/internal/users/types" 6 + "context" 7 + "html/template" 8 + "net/http" 9 + "net/http/httptest" 10 + "net/url" 11 + "path/filepath" 12 + "strings" 13 + "testing" 14 + "time" 15 + ) 16 + 17 + type testAuthClient struct{} 18 + 19 + func (t testAuthClient) GetUserEmail(_ context.Context, _ string) (string, error) { 20 + return "user@example.com", nil 21 + } 22 + 23 + func (t testAuthClient) GetUserIDFromRequest(_ *http.Request) (string, error) { 24 + return "user-1", nil 25 + } 26 + 27 + func (t testAuthClient) WithAuthHTTP(handler http.Handler) http.Handler { 28 + return handler 29 + } 30 + 31 + func (t testAuthClient) Register(_ *http.ServeMux) {} 32 + 33 + func TestHandleUser_SavesDirective(t *testing.T) { 34 + t.Parallel() 35 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 36 + storage := NewStorage(cacheStore) 37 + s := &server{ 38 + storage: storage, 39 + userTmpl: template.Must(template.New("user").Parse("ok")), 40 + clerk: testAuthClient{}, 41 + } 42 + 43 + form := url.Values{ 44 + "directive": {"Generate 5 recipes for 4 people."}, 45 + } 46 + req := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(form.Encode())) 47 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 48 + rr := httptest.NewRecorder() 49 + 50 + s.handleUser(rr, req) 51 + 52 + if rr.Code != http.StatusOK { 53 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 54 + } 55 + 56 + user, err := storage.GetByID("user-1") 57 + if err != nil { 58 + t.Fatalf("expected user to be stored, got error %v", err) 59 + } 60 + if got, want := user.Directive, "Generate 5 recipes for 4 people."; got != want { 61 + t.Fatalf("expected directive %q, got %q", want, got) 62 + } 63 + } 64 + 65 + func TestHandleUser_ClearsDirective(t *testing.T) { 66 + t.Parallel() 67 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 68 + storage := NewStorage(cacheStore) 69 + s := &server{ 70 + storage: storage, 71 + userTmpl: template.Must(template.New("user").Parse("ok")), 72 + clerk: testAuthClient{}, 73 + } 74 + 75 + existing := &utypes.User{ 76 + ID: "user-1", 77 + Email: []string{"user@example.com"}, 78 + CreatedAt: time.Now(), 79 + ShoppingDay: "Saturday", 80 + Directive: "Old prompt", 81 + } 82 + if err := storage.Update(existing); err != nil { 83 + t.Fatalf("failed to seed user: %v", err) 84 + } 85 + 86 + form := url.Values{ 87 + "directive": {""}, 88 + } 89 + req := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(form.Encode())) 90 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 91 + rr := httptest.NewRecorder() 92 + 93 + s.handleUser(rr, req) 94 + 95 + if rr.Code != http.StatusOK { 96 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 97 + } 98 + 99 + user, err := storage.GetByID("user-1") 100 + if err != nil { 101 + t.Fatalf("expected user to be stored, got error %v", err) 102 + } 103 + if user.Directive != "" { 104 + t.Fatalf("expected generation prompt to be cleared, got %q", user.Directive) 105 + } 106 + }
+1
internal/users/types/types.go
··· 24 24 FavoriteStore string `json:"favorite_store,omitempty"` 25 25 ShoppingDay string `json:"shopping_day,omitempty"` 26 26 MailOptIn bool `json:"mail_opt_in,omitempty"` 27 + Directive string `json:"directive,omitempty"` 27 28 } 28 29 29 30 // need to take a look up to location cache?