Skip to content

Commit 274c4ae

Browse files
authored
Merge pull request #23 from adab-tech/claude/globalopportunities-deploy-yukhn1
2 parents e583c99 + 3c07f94 commit 274c4ae

5 files changed

Lines changed: 116 additions & 14 deletions

File tree

backend/.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ MAX_RETRIES=3
1717
RSS_MAX_ENTRIES_PER_FEED=25
1818

1919
# ── Email alerts (optional) ──────────────────────────────────
20-
# Leave both unset to log emails instead of sending them (see
21-
# app/services/email_sender.py). Set one to send for real — if both
22-
# are set, Resend takes priority.
20+
# Leave all unset to log emails instead of sending them (see
21+
# app/services/email_sender.py). Set one to send for real — if more
22+
# than one is set, Resend takes priority, then Brevo.
2323
# RESEND_API_KEY=your_resend_api_key_here
2424
# BREVO_API_KEY=your_brevo_api_key_here
25+
# SENDGRID_API_KEY=your_sendgrid_api_key_here
2526
# ALERT_FROM_EMAIL=Global Opportunities <alerts@yourdomain.com>
2627

2728
# ── Deployment ──────────────────────────────────────────────

backend/app/config.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,17 @@ def normalize_database_url(cls, value):
4343
ENABLE_SCHEDULER: bool = True
4444
CORS_ORIGINS: str = "*"
4545

46-
# Email alerts — with neither key set, emails are logged, not
46+
# Email alerts — with none of these keys set, emails are logged, not
4747
# actually sent (see app/services/email_sender.py). Set one of
48-
# RESEND_API_KEY or BREVO_API_KEY to send for real; if both are set,
49-
# Resend takes priority. PUBLIC_BASE_URL is used to build the
50-
# manage-your-alerts link in outgoing emails. Left unset by default
51-
# so it always reflects the actual API_PORT in local dev; set it
52-
# explicitly in production (e.g. your Render URL).
48+
# RESEND_API_KEY, BREVO_API_KEY, or SENDGRID_API_KEY to send for
49+
# real; if more than one is set, Resend takes priority, then Brevo.
50+
# PUBLIC_BASE_URL is used to build the manage-your-alerts link in
51+
# outgoing emails. Left unset by default so it always reflects the
52+
# actual API_PORT in local dev; set it explicitly in production
53+
# (e.g. your Render URL).
5354
RESEND_API_KEY: str | None = None
5455
BREVO_API_KEY: str | None = None
56+
SENDGRID_API_KEY: str | None = None
5557
ALERT_FROM_EMAIL: str = "Global Opportunities <alerts@globalopportunities.app>"
5658
PUBLIC_BASE_URL: str | None = None
5759
ALERT_DIGEST_INTERVAL_HOURS: int = 168 # weekly

backend/app/services/email_sender.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
No email provider is required to run this app. By default, emails are
44
logged (ConsoleEmailSender) so every code path — save/alert signup,
55
manage links, weekly digests — works end-to-end with zero setup. Set
6-
RESEND_API_KEY or BREVO_API_KEY to switch to real delivery via Resend
7-
(https://resend.com) or Brevo (https://brevo.com) with no code changes.
6+
RESEND_API_KEY, BREVO_API_KEY, or SENDGRID_API_KEY to switch to real
7+
delivery via Resend (https://resend.com), Brevo (https://brevo.com), or
8+
SendGrid (https://sendgrid.com) with no code changes.
89
"""
910

1011
import logging
@@ -118,9 +119,47 @@ def send(self, message: EmailMessage) -> bool:
118119
return False
119120

120121

122+
class SendGridEmailSender(EmailSender):
123+
"""Delivers via the SendGrid v3 Mail Send API
124+
(https://docs.sendgrid.com/api-reference/mail-send/mail-send)."""
125+
126+
_ENDPOINT = "https://api.sendgrid.com/v3/mail/send"
127+
128+
def __init__(self, api_key: str, from_address: str):
129+
self._api_key = api_key
130+
# Same split as Brevo: SendGrid wants {email, name} rather than
131+
# Resend's combined "Name <email>" string.
132+
name, email = parseaddr(from_address)
133+
self._from = {"email": email, "name": name} if name else {"email": email}
134+
135+
def send(self, message: EmailMessage) -> bool:
136+
try:
137+
response = requests.post(
138+
self._ENDPOINT,
139+
headers={"Authorization": f"Bearer {self._api_key}"},
140+
json={
141+
"personalizations": [{"to": [{"email": message.to}]}],
142+
"from": self._from,
143+
"subject": message.subject,
144+
"content": [
145+
{"type": "text/plain", "value": message.text_body},
146+
{"type": "text/html", "value": message.html_body},
147+
],
148+
},
149+
timeout=10,
150+
)
151+
response.raise_for_status()
152+
return True
153+
except requests.RequestException as exc:
154+
logger.error("SendGrid email send failed for %s: %s", message.to, exc)
155+
return False
156+
157+
121158
def get_email_sender() -> EmailSender:
122159
if settings.RESEND_API_KEY:
123160
return ResendEmailSender(settings.RESEND_API_KEY, settings.ALERT_FROM_EMAIL)
124161
if settings.BREVO_API_KEY:
125162
return BrevoEmailSender(settings.BREVO_API_KEY, settings.ALERT_FROM_EMAIL)
163+
if settings.SENDGRID_API_KEY:
164+
return SendGridEmailSender(settings.SENDGRID_API_KEY, settings.ALERT_FROM_EMAIL)
126165
return ConsoleEmailSender()

backend/tests/test_email_sender.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Regression tests for app/services/email_sender.py's provider senders
2-
and get_email_sender()'s selection priority (Resend > Brevo > console)."""
2+
and get_email_sender()'s selection priority (Resend > Brevo > SendGrid >
3+
console)."""
34

45
from unittest.mock import MagicMock, patch
56

@@ -10,6 +11,7 @@
1011
ConsoleEmailSender,
1112
EmailMessage,
1213
ResendEmailSender,
14+
SendGridEmailSender,
1315
get_email_sender,
1416
)
1517

@@ -80,23 +82,81 @@ def test_send_returns_false_on_request_failure(self):
8082
assert sender.send(_MESSAGE) is False
8183

8284

85+
class TestSendGridEmailSender:
86+
def test_send_posts_expected_payload_with_split_sender(self):
87+
sender = SendGridEmailSender(
88+
"fake-sendgrid-key", "Global Opportunities <alerts@globalopportunities.app>"
89+
)
90+
mock_response = MagicMock()
91+
mock_response.raise_for_status.return_value = None
92+
93+
with patch("app.services.email_sender.requests.post", return_value=mock_response) as mock_post:
94+
assert sender.send(_MESSAGE) is True
95+
96+
_, kwargs = mock_post.call_args
97+
assert kwargs["headers"]["Authorization"] == "Bearer fake-sendgrid-key"
98+
assert kwargs["json"]["personalizations"] == [{"to": [{"email": "subscriber@example.org"}]}]
99+
assert kwargs["json"]["from"] == {
100+
"email": "alerts@globalopportunities.app",
101+
"name": "Global Opportunities",
102+
}
103+
assert kwargs["json"]["subject"] == "New matches this week"
104+
assert {"type": "text/plain", "value": "hi"} in kwargs["json"]["content"]
105+
assert {"type": "text/html", "value": "<p>hi</p>"} in kwargs["json"]["content"]
106+
107+
def test_sender_without_a_display_name_omits_name_field(self):
108+
sender = SendGridEmailSender("fake-sendgrid-key", "alerts@globalopportunities.app")
109+
mock_response = MagicMock()
110+
mock_response.raise_for_status.return_value = None
111+
112+
with patch("app.services.email_sender.requests.post", return_value=mock_response) as mock_post:
113+
sender.send(_MESSAGE)
114+
115+
_, kwargs = mock_post.call_args
116+
assert kwargs["json"]["from"] == {"email": "alerts@globalopportunities.app"}
117+
118+
def test_send_returns_false_on_request_failure(self):
119+
sender = SendGridEmailSender("fake-sendgrid-key", "alerts@globalopportunities.app")
120+
with patch("app.services.email_sender.requests.post", side_effect=requests.RequestException("boom")):
121+
assert sender.send(_MESSAGE) is False
122+
123+
83124
class TestGetEmailSender:
84125
def test_defaults_to_console_when_no_key_configured(self):
85126
with patch("app.services.email_sender.settings") as mock_settings:
86127
mock_settings.RESEND_API_KEY = None
87128
mock_settings.BREVO_API_KEY = None
129+
mock_settings.SENDGRID_API_KEY = None
88130
assert isinstance(get_email_sender(), ConsoleEmailSender)
89131

90132
def test_uses_brevo_when_only_brevo_key_set(self):
91133
with patch("app.services.email_sender.settings") as mock_settings:
92134
mock_settings.RESEND_API_KEY = None
93135
mock_settings.BREVO_API_KEY = "fake-brevo-key"
136+
mock_settings.SENDGRID_API_KEY = None
94137
mock_settings.ALERT_FROM_EMAIL = "alerts@globalopportunities.app"
95138
assert isinstance(get_email_sender(), BrevoEmailSender)
96139

97-
def test_resend_takes_priority_when_both_keys_set(self):
140+
def test_uses_sendgrid_when_only_sendgrid_key_set(self):
141+
with patch("app.services.email_sender.settings") as mock_settings:
142+
mock_settings.RESEND_API_KEY = None
143+
mock_settings.BREVO_API_KEY = None
144+
mock_settings.SENDGRID_API_KEY = "fake-sendgrid-key"
145+
mock_settings.ALERT_FROM_EMAIL = "alerts@globalopportunities.app"
146+
assert isinstance(get_email_sender(), SendGridEmailSender)
147+
148+
def test_resend_takes_priority_over_brevo_and_sendgrid(self):
98149
with patch("app.services.email_sender.settings") as mock_settings:
99150
mock_settings.RESEND_API_KEY = "fake-resend-key"
100151
mock_settings.BREVO_API_KEY = "fake-brevo-key"
152+
mock_settings.SENDGRID_API_KEY = "fake-sendgrid-key"
101153
mock_settings.ALERT_FROM_EMAIL = "alerts@globalopportunities.app"
102154
assert isinstance(get_email_sender(), ResendEmailSender)
155+
156+
def test_brevo_takes_priority_over_sendgrid(self):
157+
with patch("app.services.email_sender.settings") as mock_settings:
158+
mock_settings.RESEND_API_KEY = None
159+
mock_settings.BREVO_API_KEY = "fake-brevo-key"
160+
mock_settings.SENDGRID_API_KEY = "fake-sendgrid-key"
161+
mock_settings.ALERT_FROM_EMAIL = "alerts@globalopportunities.app"
162+
assert isinstance(get_email_sender(), BrevoEmailSender)

docs/DEPLOY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Copy `backend/.env.example` to `backend/.env` for local dev.
8484
| `CORS_ORIGINS` | Comma-separated origins, or `*` |
8585
| `PORT` | Set by Fly/Railway/Render (uvicorn listens here) |
8686
| `GOOGLE_API_KEY` / `GOOGLE_CSE_ID` | Optional; improves discovery |
87-
| `RESEND_API_KEY` / `BREVO_API_KEY` | Optional; unset means alert/save-confirmation emails are logged, not sent. If both are set, Resend takes priority. |
87+
| `RESEND_API_KEY` / `BREVO_API_KEY` / `SENDGRID_API_KEY` | Optional; unset means alert/save-confirmation emails are logged, not sent. If more than one is set, Resend takes priority, then Brevo. |
8888

8989
## Health check
9090

0 commit comments

Comments
 (0)