Coverage for polar/email/sender.py: 65%
60 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
1from abc import ABC, abstractmethod 1ab
2from collections.abc import Iterable 1ab
3from typing import Any, TypedDict 1ab
5import httpx 1ab
6import structlog 1ab
7from email_validator import validate_email 1ab
9from polar.config import EmailSender as EmailSenderType 1ab
10from polar.config import settings 1ab
11from polar.exceptions import PolarError 1ab
12from polar.logging import Logger 1ab
13from polar.worker import enqueue_job 1ab
15log: Logger = structlog.get_logger() 1ab
17DEFAULT_FROM_NAME = settings.EMAIL_FROM_NAME 1ab
18DEFAULT_FROM_EMAIL_ADDRESS = f"{settings.EMAIL_FROM_LOCAL}@{settings.EMAIL_FROM_DOMAIN}" 1ab
19DEFAULT_REPLY_TO_NAME = "Polar Support" 1ab
20DEFAULT_REPLY_TO_EMAIL_ADDRESS = "support@polar.sh" 1ab
23def to_ascii_email(email: str) -> str: 1ab
24 """
25 Convert an email address to ASCII format, possibly using punycode for internationalized domains.
26 """
27 validated_email = validate_email(email, check_deliverability=False)
28 return validated_email.ascii_email or email
31class EmailSenderError(PolarError): ... 1ab
34class SendEmailError(EmailSenderError): 1ab
35 def __init__(self, message: str) -> None: 1ab
36 super().__init__(message)
39class Attachment(TypedDict): 1ab
40 remote_url: str 1ab
41 filename: str 1ab
44class EmailSender(ABC): 1ab
45 @abstractmethod 1ab
46 async def send( 1ab
47 self,
48 *,
49 to_email_addr: str,
50 subject: str,
51 html_content: str,
52 from_name: str = DEFAULT_FROM_NAME,
53 from_email_addr: str = DEFAULT_FROM_EMAIL_ADDRESS,
54 email_headers: dict[str, str] | None = None,
55 reply_to_name: str | None = DEFAULT_REPLY_TO_NAME,
56 reply_to_email_addr: str | None = DEFAULT_REPLY_TO_EMAIL_ADDRESS,
57 attachments: Iterable[Attachment] | None = None,
58 ) -> None:
59 pass
62class LoggingEmailSender(EmailSender): 1ab
63 async def send( 1ab
64 self,
65 *,
66 to_email_addr: str,
67 subject: str,
68 html_content: str,
69 from_name: str = DEFAULT_FROM_NAME,
70 from_email_addr: str = DEFAULT_FROM_EMAIL_ADDRESS,
71 email_headers: dict[str, str] | None = None,
72 reply_to_name: str | None = DEFAULT_REPLY_TO_NAME,
73 reply_to_email_addr: str | None = DEFAULT_REPLY_TO_EMAIL_ADDRESS,
74 attachments: Iterable[Attachment] | None = None,
75 ) -> None:
76 log.info(
77 "Sending an email",
78 to_email_addr=to_ascii_email(to_email_addr),
79 subject=subject,
80 from_name=from_name,
81 from_email_addr=to_ascii_email(from_email_addr),
82 )
85class ResendEmailSender(EmailSender): 1ab
86 def __init__(self) -> None: 1ab
87 self.client = httpx.AsyncClient(
88 base_url=settings.RESEND_API_BASE_URL,
89 headers={"Authorization": f"Bearer {settings.RESEND_API_KEY}"},
90 )
92 async def send( 1ab
93 self,
94 *,
95 to_email_addr: str,
96 subject: str,
97 html_content: str,
98 from_name: str = DEFAULT_FROM_NAME,
99 from_email_addr: str = DEFAULT_FROM_EMAIL_ADDRESS,
100 email_headers: dict[str, str] | None = None,
101 reply_to_name: str | None = DEFAULT_REPLY_TO_NAME,
102 reply_to_email_addr: str | None = DEFAULT_REPLY_TO_EMAIL_ADDRESS,
103 attachments: Iterable[Attachment] | None = None,
104 ) -> None:
105 to_email_addr_ascii = to_ascii_email(to_email_addr)
106 payload: dict[str, Any] = {
107 "from": f"{from_name} <{to_ascii_email(from_email_addr)}>",
108 "to": [to_email_addr_ascii],
109 "subject": subject,
110 "html": html_content,
111 "headers": email_headers or {},
112 "attachments": [
113 {
114 "path": attachment["remote_url"],
115 "filename": attachment["filename"],
116 }
117 for attachment in attachments
118 ]
119 if attachments
120 else [],
121 }
122 if reply_to_name and reply_to_email_addr:
123 payload["reply_to"] = (
124 f"{reply_to_name} <{to_ascii_email(reply_to_email_addr)}>"
125 )
127 try:
128 response = await self.client.post("/emails", json=payload)
129 response.raise_for_status()
130 email = response.json()
131 except httpx.HTTPError as e:
132 log.warning(
133 "resend.send_error",
134 to_email_addr=to_email_addr_ascii,
135 subject=subject,
136 error=e,
137 )
138 raise SendEmailError(str(e)) from e
140 log.info(
141 "resend.send",
142 to_email_addr=to_email_addr_ascii,
143 subject=subject,
144 email_id=email["id"],
145 )
148class EmailFromReply(TypedDict): 1ab
149 from_name: str 1ab
150 from_email_addr: str 1ab
151 reply_to_name: str 1ab
152 reply_to_email_addr: str 1ab
155def enqueue_email( 1ab
156 to_email_addr: str,
157 subject: str,
158 html_content: str,
159 from_name: str = DEFAULT_FROM_NAME,
160 from_email_addr: str = DEFAULT_FROM_EMAIL_ADDRESS,
161 email_headers: dict[str, str] | None = None,
162 reply_to_name: str | None = DEFAULT_REPLY_TO_NAME,
163 reply_to_email_addr: str | None = DEFAULT_REPLY_TO_EMAIL_ADDRESS,
164 attachments: Iterable[Attachment] | None = None,
165) -> None:
166 enqueue_job(
167 "email.send",
168 to_email_addr=to_email_addr,
169 subject=subject,
170 html_content=html_content,
171 from_name=from_name,
172 from_email_addr=from_email_addr,
173 email_headers=email_headers,
174 reply_to_name=reply_to_name,
175 reply_to_email_addr=reply_to_email_addr,
176 attachments=attachments,
177 )
180email_sender: EmailSender 1ab
181if settings.EMAIL_SENDER == EmailSenderType.resend: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true1ab
182 email_sender = ResendEmailSender()
183else:
184 # Logging in development
185 email_sender = LoggingEmailSender() 1ab