diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py index 11d61b7..b936c60 100644 --- a/backend/app/services/orchestrator/steps.py +++ b/backend/app/services/orchestrator/steps.py @@ -132,7 +132,9 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: vote_url = ( f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}" ) - recipe_name = item.recipe.name if item.recipe else str(item.recipe_id) + recipe_name = html.escape( + item.recipe.name if item.recipe else str(item.recipe_id) + ) item_html_parts.append( f'
  • {recipe_name} — Vote
  • ' ) @@ -140,7 +142,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: if not item_html_parts: continue - html = ( + email_html = ( f"

    This week's meal suggestions

    " f"{stale_banner}" f"

    Hi {member.name}, please vote on this week's meals by Fri 17:00 PT:

    " @@ -150,7 +152,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: backend.send( to=member.email, subject=f"Meal plan for week of {run.week_start_date}", - html=html, + html=email_html, ) logger.info("step_email: sent to %s", member.email) @@ -177,7 +179,7 @@ def step_deadline(run: "WeeklyRun", db: "Session") -> None: return family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first() - policy = getattr(family, "pending_approval_policy", "approve") if family else "approve" + policy = family.pending_approval_policy if family else "approve" resolved = 0 for item in plan.items: @@ -227,28 +229,28 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None: if approved_items: rows_html = "".join( - f"{item.recipe.name}" - f"{ing.get('name', '')}" - f"{ing.get('qty', '')} {ing.get('unit', '')}" + f"{html.escape(item.recipe.name)}" + f"{html.escape(str(ing.get('name', '')))}" + f"{html.escape(str(ing.get('qty', '')))} {html.escape(str(ing.get('unit', '')))}" for item in approved_items if item.recipe for ing in (item.recipe.ingredients or []) ) - html = ( + email_html = ( f"

    Shopping list — week of {run.week_start_date}

    " f"

    {len(approved_items)} meal(s) approved.

    " f"" f"{rows_html}
    RecipeIngredientQty
    " ) else: - html = f"

    No meals were approved for week of {run.week_start_date}.

    " + email_html = f"

    No meals were approved for week of {run.week_start_date}.

    " backend = get_email_backend() for member in members: backend.send( to=member.email, subject=f"Shopping list — week of {run.week_start_date}", - html=html, + html=email_html, ) logger.info("step_finalize: shopping list sent to %s", member.email) diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py index d4a8c59..08fff3f 100644 --- a/backend/tests/test_orchestrator.py +++ b/backend/tests/test_orchestrator.py @@ -316,6 +316,29 @@ def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_it assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower() +def test_step_email_escapes_recipe_name( + db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch +): + pending_item.recipe.name = "" + db.flush() + + sent = [] + + class FakeBackend: + def send(self, **kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "app.services.orchestrator.steps.get_email_backend", + lambda: FakeBackend(), + ) + from app.services.orchestrator.steps import step_email + step_email(weekly_run_generated, db) + assert len(sent) == 1 + assert "