feat: sponsor blast endpoint with pitch engine and rate limiting #325

Closed
opened 2026-04-03 23:51:55 +00:00 by forgejo_admin · 0 comments

Type

Feature

Lineage

Child of basketball-api#316 (sponsor outreach system). Blocked by: #321 (model + CRUD). Depends on: #320 (MJML template must exist on disk).

Repo

forgejo_admin/basketball-api

User Story

As an admin,
I want to blast sponsor outreach emails filtered by category and track who was contacted,
So that I can run targeted campaigns and follow up with non-responders.

Context

This ticket adds the blast endpoint and pitch engine on top of the Sponsor model (created in #321). It sends branded emails using the sponsor-outreach.html template (#320) via the existing load_email_template() + get_gmail_client() infrastructure.

Key behaviors:

  • Category pitch defaults (developer-managed dict, not DB) — derived from Marcus's actual email copy
  • Per-sponsor custom_pitch override
  • Rate limiting (3-5s between sends) to avoid Gmail spam detection
  • test_email safety valve for previewing before blast
  • Status updates: prospect → contacted on send
  • Full email_log audit trail

File Targets

Files the agent should modify:

  • src/basketball_api/services/sponsor_service.py — add blast_sponsors(), get_pitch(), CATEGORY_PITCHES dict
  • src/basketball_api/routes/sponsors.py — add POST /sponsors/blast endpoint

Files the agent should create:

  • tests/test_sponsor_blast.py — blast-specific tests

Files the agent should reference (read, not modify):

  • src/basketball_api/services/email.py — import load_email_template, get_gmail_client, EmailLog, EmailType
  • src/basketball_api/models.py — Sponsor, SponsorStatus, SponsorCategory, EmailType

Files the agent should NOT touch:

  • src/basketball_api/services/email.py — do not modify, only import from
  • src/basketball_api/models.py — already modified in #321
  • Existing email templates or routes

Acceptance Criteria

  • CATEGORY_PITCHES dict with defaults for all 9 categories (food, financial, retail, automotive, construction, fitness, dental, grocery, other)
  • get_pitch(sponsor) returns sponsor.custom_pitch if set, otherwise CATEGORY_PITCHES[sponsor.category]
  • POST /sponsors/blast accepts: category (optional), status (optional, default "prospect"), include_tiers (bool), test_email (optional)
  • When test_email is set: sends ONE email to that address using first matching sponsor's data, does NOT update any sponsor status
  • When test_email is null: sends to all matching sponsors with 3-5 second delay between sends
  • Each send: updates sponsor.status to "contacted", sets last_contacted_at to now()
  • Each send: creates EmailLog entry with EmailType.sponsor_outreach
  • Uses load_email_template("sponsor-outreach", data) for rendering
  • Template data includes: business_name, pitch, sender_name ("Marcus Draney"), sender_phone ("385-450-9963"), sponsorship_tiers (rendered HTML block or empty string)
  • Returns JSON: {"sent": N, "failed": N, "sponsors": [{"id": ..., "business_name": ..., "email": ...}]}
  • Re-blasting status=contacted sponsors works for follow-ups (updates last_contacted_at again)

Category Pitch Defaults

Derived from Marcus's actual GroupMe emails:

  • food: "We have 70+ athletes and families involved, and we're constantly traveling, practicing, and eating together as a group — so we're always supporting local food spots. We wanted to reach out and see if you'd be interested in partnering with us this season. This could look like team meals or discounts, sponsorship support, or promotion to all of our families and players. We would actively push our entire program to support your business and build a long-term relationship."
  • financial: "We work with 70+ athletes from West Valley and surrounding communities, helping them develop and get exposure for college opportunities. We wanted to reach out and see if there's any way you might be open to supporting our program — whether that's a sponsorship, donation, or any type of community support. We would love to represent and promote your business through our teams, families, and social media."
  • retail: "We work with 70+ athletes, including local and international players, helping them develop and get recruited for college opportunities. We're reaching out to see if you'd be open to partnering with us as a sponsor this season. We offer logo placement on team gear, social media promotion, and exposure to our families and community. We also love supporting businesses that support us — sending our families your way and building a real local connection."
  • automotive: Same as retail
  • construction: "We work with 70+ athletes from West Valley and surrounding communities, helping them develop, stay on the right path, and earn opportunities to play at the next level. We wanted to reach out and see if there's any way you might be open to supporting our program — whether that's a donation, sponsorship, or any type of community support. We're big on representing the people who support us, and we'd make sure to promote your company through our teams, families, and social media."
  • fitness: "We work closely with athletes from West Valley, West Jordan, and surrounding communities, helping them earn college opportunities through basketball. We're currently looking to partner with a few strong local businesses. Our sponsorships include logo placement on uniforms and team gear, consistent social media exposure, and direct visibility to 70+ families and community members. We're focused on building long-term partnerships that benefit both sides."
  • dental: Same as construction
  • grocery: "We have 70+ athletes and families involved, and we're constantly traveling, practicing, and supporting local businesses. We wanted to reach out and see if there's any way you'd be open to supporting our program — whether that's food support, gift cards, or any type of donation. We would love to support your store right back by bringing our teams and families your way."
  • other: "We work with 70+ athletes and families from the Westside community. We wanted to reach out and see if there's any way you'd be open to supporting our program — whether that's sponsorship, a donation, or any type of community support. We'd make sure to represent and promote your business through our teams, families, and social media. Anything helps, and we're just looking to build strong local partnerships."

Sponsorship Tiers Block (HTML for template)

When include_tiers=true, render this as the {{sponsorship_tiers}} placeholder:

  • TITLE SPONSOR — $5,000+ (Logo on jerseys, warm-ups, backpacks; featured website placement; weekly Instagram; event banners; "Official Partner" recognition)
  • ELITE SPONSOR — $2,500 (Logo on warm-ups, backpacks; website sponsor section; bi-weekly Instagram)
  • TEAM SPONSOR — $1,000 (Website sponsor page; monthly Instagram; event recognition)
  • SOCIAL SPONSOR — $500 (4 dedicated Instagram shout-outs; website sponsor page)
  • PLAYER SPONSORSHIP — $300–$1,200 (Sponsor a player's tournament fees, travel, uniform, training; thank-you post; website recognition; "Community Impact Partner" highlight)

When include_tiers=false, {{sponsorship_tiers}} = empty string.

Test Expectations

  • Unit: get_pitch returns custom_pitch when set
  • Unit: get_pitch returns category default when custom_pitch is None
  • Unit: CATEGORY_PITCHES has entries for all 9 categories
  • Integration: POST /sponsors/blast with test_email sends 1, returns sent=1, does NOT update sponsor status
  • Integration: POST /sponsors/blast without test_email sends N, updates statuses to contacted
  • Integration: blast with category filter only sends to that category
  • Integration: blast with status=contacted works for follow-ups
  • Run command: pytest tests/test_sponsor_blast.py -v

Constraints

  • Import from email.py, do not modify it
  • Rate limiting: time.sleep(3) between sends (not async)
  • Sender identity: westsidebasketball@gmail.com via existing Gmail OAuth
  • Follow sop-email-send approval pattern (test_email → check → blast)

Checklist

  • PR opened
  • Tests pass
  • No unrelated changes
  • basketball-api#316 — parent epic
  • basketball-api#321 — model + CRUD (must merge first)
  • basketball-api#320 — MJML template (must exist on disk)
### Type Feature ### Lineage Child of basketball-api#316 (sponsor outreach system). Blocked by: #321 (model + CRUD). Depends on: #320 (MJML template must exist on disk). ### Repo `forgejo_admin/basketball-api` ### User Story As an admin, I want to blast sponsor outreach emails filtered by category and track who was contacted, So that I can run targeted campaigns and follow up with non-responders. ### Context This ticket adds the blast endpoint and pitch engine on top of the Sponsor model (created in #321). It sends branded emails using the sponsor-outreach.html template (#320) via the existing `load_email_template()` + `get_gmail_client()` infrastructure. Key behaviors: - Category pitch defaults (developer-managed dict, not DB) — derived from Marcus's actual email copy - Per-sponsor `custom_pitch` override - Rate limiting (3-5s between sends) to avoid Gmail spam detection - test_email safety valve for previewing before blast - Status updates: prospect → contacted on send - Full email_log audit trail ### File Targets Files the agent should modify: - `src/basketball_api/services/sponsor_service.py` — add blast_sponsors(), get_pitch(), CATEGORY_PITCHES dict - `src/basketball_api/routes/sponsors.py` — add POST /sponsors/blast endpoint Files the agent should create: - `tests/test_sponsor_blast.py` — blast-specific tests Files the agent should reference (read, not modify): - `src/basketball_api/services/email.py` — import load_email_template, get_gmail_client, EmailLog, EmailType - `src/basketball_api/models.py` — Sponsor, SponsorStatus, SponsorCategory, EmailType Files the agent should NOT touch: - `src/basketball_api/services/email.py` — do not modify, only import from - `src/basketball_api/models.py` — already modified in #321 - Existing email templates or routes ### Acceptance Criteria - [ ] `CATEGORY_PITCHES` dict with defaults for all 9 categories (food, financial, retail, automotive, construction, fitness, dental, grocery, other) - [ ] `get_pitch(sponsor)` returns `sponsor.custom_pitch` if set, otherwise `CATEGORY_PITCHES[sponsor.category]` - [ ] `POST /sponsors/blast` accepts: category (optional), status (optional, default "prospect"), include_tiers (bool), test_email (optional) - [ ] When test_email is set: sends ONE email to that address using first matching sponsor's data, does NOT update any sponsor status - [ ] When test_email is null: sends to all matching sponsors with 3-5 second delay between sends - [ ] Each send: updates sponsor.status to "contacted", sets last_contacted_at to now() - [ ] Each send: creates EmailLog entry with EmailType.sponsor_outreach - [ ] Uses `load_email_template("sponsor-outreach", data)` for rendering - [ ] Template data includes: business_name, pitch, sender_name ("Marcus Draney"), sender_phone ("385-450-9963"), sponsorship_tiers (rendered HTML block or empty string) - [ ] Returns JSON: `{"sent": N, "failed": N, "sponsors": [{"id": ..., "business_name": ..., "email": ...}]}` - [ ] Re-blasting status=contacted sponsors works for follow-ups (updates last_contacted_at again) ### Category Pitch Defaults Derived from Marcus's actual GroupMe emails: - **food**: "We have 70+ athletes and families involved, and we're constantly traveling, practicing, and eating together as a group — so we're always supporting local food spots. We wanted to reach out and see if you'd be interested in partnering with us this season. This could look like team meals or discounts, sponsorship support, or promotion to all of our families and players. We would actively push our entire program to support your business and build a long-term relationship." - **financial**: "We work with 70+ athletes from West Valley and surrounding communities, helping them develop and get exposure for college opportunities. We wanted to reach out and see if there's any way you might be open to supporting our program — whether that's a sponsorship, donation, or any type of community support. We would love to represent and promote your business through our teams, families, and social media." - **retail**: "We work with 70+ athletes, including local and international players, helping them develop and get recruited for college opportunities. We're reaching out to see if you'd be open to partnering with us as a sponsor this season. We offer logo placement on team gear, social media promotion, and exposure to our families and community. We also love supporting businesses that support us — sending our families your way and building a real local connection." - **automotive**: Same as retail - **construction**: "We work with 70+ athletes from West Valley and surrounding communities, helping them develop, stay on the right path, and earn opportunities to play at the next level. We wanted to reach out and see if there's any way you might be open to supporting our program — whether that's a donation, sponsorship, or any type of community support. We're big on representing the people who support us, and we'd make sure to promote your company through our teams, families, and social media." - **fitness**: "We work closely with athletes from West Valley, West Jordan, and surrounding communities, helping them earn college opportunities through basketball. We're currently looking to partner with a few strong local businesses. Our sponsorships include logo placement on uniforms and team gear, consistent social media exposure, and direct visibility to 70+ families and community members. We're focused on building long-term partnerships that benefit both sides." - **dental**: Same as construction - **grocery**: "We have 70+ athletes and families involved, and we're constantly traveling, practicing, and supporting local businesses. We wanted to reach out and see if there's any way you'd be open to supporting our program — whether that's food support, gift cards, or any type of donation. We would love to support your store right back by bringing our teams and families your way." - **other**: "We work with 70+ athletes and families from the Westside community. We wanted to reach out and see if there's any way you'd be open to supporting our program — whether that's sponsorship, a donation, or any type of community support. We'd make sure to represent and promote your business through our teams, families, and social media. Anything helps, and we're just looking to build strong local partnerships." ### Sponsorship Tiers Block (HTML for template) When include_tiers=true, render this as the `{{sponsorship_tiers}}` placeholder: - TITLE SPONSOR — $5,000+ (Logo on jerseys, warm-ups, backpacks; featured website placement; weekly Instagram; event banners; "Official Partner" recognition) - ELITE SPONSOR — $2,500 (Logo on warm-ups, backpacks; website sponsor section; bi-weekly Instagram) - TEAM SPONSOR — $1,000 (Website sponsor page; monthly Instagram; event recognition) - SOCIAL SPONSOR — $500 (4 dedicated Instagram shout-outs; website sponsor page) - PLAYER SPONSORSHIP — $300–$1,200 (Sponsor a player's tournament fees, travel, uniform, training; thank-you post; website recognition; "Community Impact Partner" highlight) When include_tiers=false, `{{sponsorship_tiers}}` = empty string. ### Test Expectations - [ ] Unit: get_pitch returns custom_pitch when set - [ ] Unit: get_pitch returns category default when custom_pitch is None - [ ] Unit: CATEGORY_PITCHES has entries for all 9 categories - [ ] Integration: POST /sponsors/blast with test_email sends 1, returns sent=1, does NOT update sponsor status - [ ] Integration: POST /sponsors/blast without test_email sends N, updates statuses to contacted - [ ] Integration: blast with category filter only sends to that category - [ ] Integration: blast with status=contacted works for follow-ups - Run command: `pytest tests/test_sponsor_blast.py -v` ### Constraints - Import from email.py, do not modify it - Rate limiting: `time.sleep(3)` between sends (not async) - Sender identity: westsidebasketball@gmail.com via existing Gmail OAuth - Follow sop-email-send approval pattern (test_email → check → blast) ### Checklist - [ ] PR opened - [ ] Tests pass - [ ] No unrelated changes ### Related - basketball-api#316 — parent epic - basketball-api#321 — model + CRUD (must merge first) - basketball-api#320 — MJML template (must exist on disk)
forgejo_admin 2026-04-06 15:57:13 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/basketball-api#325
No description provided.