Skip to content

Commit cd321f1

Browse files
committed
Refactor to django-anymail from resend client and add tests
1 parent 3ae79db commit cd321f1

14 files changed

Lines changed: 469 additions & 169 deletions

File tree

.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ OPENROUTER_API_KEY=
1111
OPENROUTER_API_BASE=https://openrouter.ai/api/v1
1212
OPENROUTER_APP_URL=
1313
OPENROUTER_APP_NAME=newsletter-maker
14+
1415
AI_CLASSIFICATION_MODEL=meta-llama/llama-3.1-70b-instruct
1516
AI_RELEVANCE_MODEL=qwen/qwen-2.5-72b-instruct
1617
AI_SUMMARIZATION_MODEL=google/gemma-3-27b-it
@@ -21,14 +22,39 @@ AI_RELEVANCE_REVIEW_THRESHOLD=0.4
2122
AI_RELEVANCE_SUMMARIZE_THRESHOLD=0.7
2223
AI_MAX_NODE_RETRIES=2
2324
AI_REQUEST_TIMEOUT_SECONDS=60
25+
2426
EMBEDDING_PROVIDER=sentence-transformers
2527
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
2628
EMBEDDING_TRUST_REMOTE_CODE=false
29+
2730
OLLAMA_URL=http://localhost:11434
31+
2832
REDDIT_CLIENT_ID=
2933
REDDIT_CLIENT_SECRET=
3034
REDDIT_USER_AGENT=newsletter-maker/0.1
35+
36+
# Outbound mail provider. Use Resend or Amazon SES.
37+
EMAIL_BACKEND=anymail.backends.resend.EmailBackend
38+
DEFAULT_FROM_EMAIL=onboarding@resend.dev
39+
SERVER_EMAIL=onboarding@resend.dev
40+
41+
# Resend outbound + inbound
42+
RESEND_API_KEY=
43+
RESEND_FROM_EMAIL=onboarding@resend.dev
44+
RESEND_INBOUND_SECRET=
45+
46+
# Amazon SES outbound + inbound
47+
# EMAIL_BACKEND=anymail.backends.amazon_ses.EmailBackend
48+
# AWS_ACCESS_KEY_ID=
49+
# AWS_SECRET_ACCESS_KEY=
50+
# AWS_DEFAULT_REGION=us-east-1
51+
52+
# Shared webhook basic auth for providers that need it.
53+
# Format must be username:password.
54+
ANYMAIL_WEBHOOK_SECRET=
55+
3156
LOG_LEVEL=INFO
57+
3258
CELERY_TASK_ALWAYS_EAGER=false
3359

3460
DJANGO_SUPERUSER_USERNAME=admin
@@ -40,5 +66,7 @@ NEWSLETTER_API_USERNAME=admin
4066
NEWSLETTER_API_PASSWORD=adminpass
4167

4268
DEBUG=True
69+
4370
ALLOWED_HOSTS=localhost,127.0.0.1,newslettermaker.tech
71+
4472
FRONTEND_URL=http://localhost:3000

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
{
22
"cSpell.words": [
33
"ASGI",
4+
"botocore",
45
"buildx",
56
"cbranch",
67
"cfgv",
78
"cstat",
9+
"dateutil",
810
"djlint",
911
"FAVICONS",
1012
"Feedly",
@@ -33,6 +35,7 @@
3335
"readyz",
3436
"Referer",
3537
"simplejwt",
38+
"svix",
3639
"Unparseable",
3740
"unstub",
3841
"upserted",

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,9 @@ For the default local bootstrap, `.env` also seeds an `admin` superuser in the c
177177
This repository is licensed under the GNU Affero General Public License v3.0 or later. See [LICENSE](LICENSE).
178178

179179
Based on the current direct dependencies, AGPL is a reasonable fit: the packages in use are permissive or LGPL-compatible licenses such as BSD, MIT, Apache-2.0, and LGPLv3. That said, this is a practical compatibility check, not legal advice, so review it with counsel if you need a formal licensing opinion.
180+
181+
In our skills/relevance_scoring/SKILL.md, I see that we're passing the tenant id. Are we evaluating the content item's relevance against all content items for a project (tenant)? Or how is the relevance actually calculated?
182+
183+
I notice too we're still using "tenant_id". We changed our naming scheme from "tenant" to "project". We need to update the skills and anywhere else we're still referring to the old "tenant" naming scheme.
184+
185+
How many Embedding Dimensions are we using to create embeddings? Where are we specifying that value for Qdrant records?

core/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
class CoreConfig(AppConfig):
55
default_auto_field = "django.db.models.BigAutoField"
66
name = "core"
7+
8+
def ready(self) -> None:
9+
import core.signals # noqa: F401

core/newsletters.py

Lines changed: 97 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from __future__ import annotations
22

3-
import hashlib
4-
import hmac
53
import re
64
from dataclasses import dataclass
75
from email.utils import parseaddr
86
from html.parser import HTMLParser
9-
from typing import Any, cast
7+
from typing import Any, Iterable, cast
108

119
from django.conf import settings as django_settings
10+
from django.core.mail import EmailMultiAlternatives
11+
from django.urls import reverse
1212

13+
from core.models import IntakeAllowlist, NewsletterIntake, Project
1314
from core.settings_types import CoreSettings
1415

1516
settings = cast(CoreSettings, django_settings)
@@ -29,17 +30,6 @@ def sanitize_newsletter_html(raw_html: str) -> str:
2930
return INLINE_HANDLER_PATTERN.sub("", without_scripts)
3031

3132

32-
def compute_resend_signature(payload: bytes, secret: str) -> str:
33-
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
34-
35-
36-
def verify_resend_signature(payload: bytes, provided_signature: str) -> bool:
37-
if not settings.RESEND_WEBHOOK_SECRET or not provided_signature:
38-
return False
39-
expected_signature = compute_resend_signature(payload, settings.RESEND_WEBHOOK_SECRET)
40-
return hmac.compare_digest(expected_signature, provided_signature)
41-
42-
4333
def extract_project_token(recipient: str) -> str | None:
4434
_, email_address = parseaddr(recipient)
4535
local_part = email_address.partition("@")[0]
@@ -50,24 +40,101 @@ def extract_project_token(recipient: str) -> str | None:
5040

5141

5242
def send_confirmation_email(*, to_email: str, confirm_url: str, project_name: str) -> None:
53-
if not settings.RESEND_API_KEY:
54-
raise RuntimeError("RESEND_API_KEY must be configured to send newsletter confirmation emails.")
55-
56-
import resend
57-
58-
resend.api_key = settings.RESEND_API_KEY
59-
resend.Emails.send(
60-
{
61-
"from": settings.RESEND_FROM_EMAIL,
62-
"to": [to_email],
63-
"subject": f"Confirm newsletter intake for {project_name}",
64-
"html": (
65-
"<p>Confirm this sender for newsletter ingestion.</p>"
66-
f'<p><a href="{confirm_url}">Confirm sender</a></p>'
67-
),
68-
}
43+
subject = f"Confirm newsletter intake for {project_name}"
44+
text_body = (
45+
"Confirm this sender for newsletter ingestion.\n\n"
46+
f"Confirm sender: {confirm_url}"
47+
)
48+
html_body = (
49+
"<p>Confirm this sender for newsletter ingestion.</p>"
50+
f'<p><a href="{confirm_url}">Confirm sender</a></p>'
6951
)
7052

53+
message = EmailMultiAlternatives(
54+
subject=subject,
55+
body=text_body,
56+
from_email=settings.DEFAULT_FROM_EMAIL,
57+
to=[to_email],
58+
)
59+
message.attach_alternative(html_body, "text/html")
60+
message.send()
61+
62+
63+
def build_confirmation_url(token: str) -> str:
64+
base_url = settings.NEWSLETTER_API_BASE_URL.rstrip("/")
65+
return f"{base_url}{reverse('confirm-newsletter-sender', kwargs={'token': token})}"
66+
67+
68+
def process_inbound_newsletter(
69+
*,
70+
recipients: Iterable[str],
71+
sender_email: str,
72+
subject: str,
73+
raw_html: str,
74+
raw_text: str,
75+
message_id: str,
76+
) -> dict[str, Any]:
77+
project = _find_intake_project(recipients)
78+
if project is None:
79+
return {"status": "ignored", "reason": "no_matching_project"}
80+
81+
normalized_sender_email = normalize_sender_email(sender_email)
82+
normalized_message_id = message_id.strip()
83+
if not normalized_sender_email or not normalized_message_id:
84+
return {"status": "ignored", "reason": "missing_sender_or_message_id"}
85+
86+
defaults = {
87+
"project": project,
88+
"sender_email": normalized_sender_email,
89+
"subject": subject[:512],
90+
"raw_html": sanitize_newsletter_html(raw_html),
91+
"raw_text": raw_text,
92+
}
93+
intake, created = NewsletterIntake.objects.get_or_create(
94+
message_id=normalized_message_id,
95+
defaults=defaults,
96+
)
97+
if not created:
98+
return {"id": intake.id, "status": intake.status, "duplicate": True}
99+
100+
allowlist, allowlist_created = IntakeAllowlist.objects.get_or_create(
101+
project=project,
102+
sender_email=normalized_sender_email,
103+
)
104+
105+
if allowlist.is_confirmed:
106+
queue_newsletter_intake(intake.id)
107+
return {"id": intake.id, "status": intake.status}
108+
109+
if allowlist_created:
110+
send_confirmation_email(
111+
to_email=normalized_sender_email,
112+
confirm_url=build_confirmation_url(allowlist.confirmation_token),
113+
project_name=project.name,
114+
)
115+
116+
return {"id": intake.id, "status": intake.status, "confirmation_required": True}
117+
118+
119+
def queue_newsletter_intake(intake_id: int) -> None:
120+
from core.tasks import process_newsletter_intake
121+
122+
if settings.CELERY_TASK_ALWAYS_EAGER:
123+
process_newsletter_intake(intake_id)
124+
else:
125+
process_newsletter_intake.delay(intake_id)
126+
127+
128+
def _find_intake_project(recipients: Iterable[str]) -> Project | None:
129+
for recipient in recipients:
130+
token = extract_project_token(recipient)
131+
if token is None:
132+
continue
133+
project = Project.objects.filter(intake_token=token, intake_enabled=True).first()
134+
if project is not None:
135+
return project
136+
return None
137+
71138

72139
@dataclass(slots=True)
73140
class ExtractedNewsletterItem:
@@ -146,10 +213,3 @@ def extract_newsletter_items(*, subject: str, raw_html: str, raw_text: str) -> l
146213
)
147214

148215
return extracted_items
149-
150-
151-
def get_resend_payload_data(payload: dict[str, Any]) -> dict[str, Any]:
152-
data = payload.get("data")
153-
if isinstance(data, dict):
154-
return data
155-
return payload

core/settings_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
class CoreSettings(Protocol):
55
CELERY_TASK_ALWAYS_EAGER: bool
6+
DEFAULT_FROM_EMAIL: str
7+
NEWSLETTER_API_BASE_URL: str
68
QDRANT_URL: str
79
EMBEDDING_MODEL: str
810
EMBEDDING_PROVIDER: str
@@ -13,5 +15,3 @@ class CoreSettings(Protocol):
1315
OPENROUTER_APP_URL: str
1416
OPENROUTER_APP_NAME: str
1517
RESEND_API_KEY: str
16-
RESEND_FROM_EMAIL: str
17-
RESEND_WEBHOOK_SECRET: str

core/signals.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
from anymail.signals import inbound
4+
from django.dispatch import receiver
5+
6+
from core.newsletters import process_inbound_newsletter
7+
8+
9+
def _address_to_string(address) -> str:
10+
if address is None:
11+
return ""
12+
addr_spec = getattr(address, "addr_spec", None)
13+
if isinstance(addr_spec, str):
14+
return addr_spec.strip()
15+
return str(address).strip()
16+
17+
18+
@receiver(inbound)
19+
def handle_anymail_inbound(sender, event, esp_name, **kwargs):
20+
message = event.message
21+
22+
recipients: list[str] = []
23+
if message.envelope_recipient:
24+
recipients.append(message.envelope_recipient)
25+
recipients.extend(
26+
address.addr_spec
27+
for address in getattr(message, "to", [])
28+
if getattr(address, "addr_spec", "")
29+
)
30+
31+
process_inbound_newsletter(
32+
recipients=recipients,
33+
sender_email=message.envelope_sender or _address_to_string(getattr(message, "from_email", None)),
34+
subject=message.subject or "",
35+
raw_html=message.html or "",
36+
raw_text=message.text or "",
37+
message_id=str(message.get("Message-ID", "") or event.event_id or ""),
38+
)

0 commit comments

Comments
 (0)