File size: 11,577 Bytes
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e1198f
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2823b28
 
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811ee7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
b74c6ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e1198f
 
 
 
 
 
 
00a4363
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""EmailService over a provider interface (Resend in prod, console locally).

Rule: no em dashes in any copy. Broadcast bodies are plain text and get HTML escaped.
"""

import hashlib
import hmac
import html
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass

from app.config import settings

log = logging.getLogger(__name__)


@dataclass
class OutgoingEmail:
    to: str
    subject: str
    html: str
    text: str
    headers: dict[str, str] | None = None


class BaseEmailProvider(ABC):
    @abstractmethod
    def send(self, email: OutgoingEmail) -> str | None: ...


class ResendEmailProvider(BaseEmailProvider):
    def __init__(self, api_key: str):
        import resend

        resend.api_key = api_key
        self._resend = resend

    def send(self, email: OutgoingEmail) -> str | None:
        params = {
            "from": settings.from_email,
            "to": [email.to],
            "subject": email.subject,
            "html": email.html,
            "text": email.text,
        }
        if email.headers:
            params["headers"] = email.headers
        result = self._resend.Emails.send(params)
        return result.get("id") if isinstance(result, dict) else None


class ConsoleEmailProvider(BaseEmailProvider):
    sent: list[OutgoingEmail] = []

    def send(self, email: OutgoingEmail) -> str | None:
        ConsoleEmailProvider.sent.append(email)
        # Warning, not info: locally this is the only place a sign up or reset code shows up.
        log.warning("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
        return None


def make_unsubscribe_token(user_id: str) -> str:
    sig = hmac.new(settings.unsubscribe_secret.encode(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
    return f"{user_id}.{sig}"


def verify_unsubscribe_token(token: str) -> str | None:
    user_id, _, _sig = token.partition(".")
    if user_id and hmac.compare_digest(make_unsubscribe_token(user_id), token):
        return user_id
    return None


def _layout(title: str, body_html: str, footer_html: str = "") -> str:
    # Inline styles only; email clients ignore <style>. Black, #217cff, white.
    return f"""<!doctype html><html><body style="margin:0;background:#000000;padding:32px 16px;
font-family:Inter,Arial,sans-serif;color:#ffffff">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;
border:1px solid rgba(33,124,255,0.35);border-radius:16px;padding:32px;background:#000000">
<tr><td style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#217cff">Notestack</td></tr>
<tr><td style="padding-top:16px;font-size:22px;font-weight:600;color:#ffffff">{html.escape(title)}</td></tr>
<tr><td style="padding-top:16px;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.85)">{body_html}</td></tr>
<tr><td style="padding-top:28px;font-size:12px;color:rgba(255,255,255,0.5)">{footer_html}</td></tr>
</table></td></tr></table></body></html>"""


def _button(label: str, url: str) -> str:
    return (
        f'<p style="padding-top:12px"><a href="{html.escape(url)}" style="display:inline-block;'
        f"background:#217cff;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:999px;"
        f'font-weight:600">{html.escape(label)}</a></p>'
    )


def _code_block(code: str) -> str:
    return (
        f'<p style="font-family:JetBrains Mono,monospace;font-size:32px;letter-spacing:0.3em;'
        f'color:#ffffff;padding:8px 0">{html.escape(code)}</p>'
    )


class EmailService:
    def __init__(self, provider: BaseEmailProvider):
        self.provider = provider

    def _send(self, email: OutgoingEmail) -> bool:
        try:
            self.provider.send(email)
            return True
        except Exception:  # never let email failures break the request
            log.exception("email send failed to=%s subject=%s", email.to, email.subject)
            return False

    # Transactional

    def send_verification_code(self, to: str, code: str) -> bool:
        body = f"Enter this code to finish creating your account:{_code_block(code)}It expires in 10 minutes."
        return self._send(
            OutgoingEmail(
                to=to,
                subject=f"{code} is your Notestack code",
                html=_layout("Confirm your email", body, "If you did not sign up, ignore this email."),
                text=f"Your Notestack code is {code}. It expires in 10 minutes.",
            )
        )

    def send_password_reset_code(self, to: str, code: str) -> bool:
        body = f"Use this code to reset your password:{_code_block(code)}It expires in 10 minutes."
        return self._send(
            OutgoingEmail(
                to=to,
                subject="Reset your Notestack password",
                html=_layout("Password reset", body, "If you did not ask for this, you can ignore it."),
                text=f"Your Notestack password reset code is {code}. It expires in 10 minutes.",
            )
        )

    def send_welcome(self, to: str, name: str | None, user_id: str) -> bool:
        first = (name or "there").split(" ")[0]
        url = f"{settings.frontend_url}/app"
        body = (
            f"Hi {html.escape(first)},<br><br>Your knowledge is about to come into orbit. Connect any blog, newsletter "
            "or site, or upload your markdown files, and Notestack will index your posts, learn your voice and get "
            "your first audio overview ready."
            + _button("Open Mission Control", url)
        )
        return self._send(
            OutgoingEmail(
                to=to,
                subject="Welcome to Notestack",
                html=_layout("Welcome aboard", body, self._unsubscribe_footer(user_id)),
                text=f"Hi {first}, welcome to Notestack. Open Mission Control: {url}",
                headers=self._list_unsubscribe(user_id),
            )
        )

    def send_job_ready(self, to: str, what: str, url: str) -> bool:
        body = f"Your {html.escape(what)} is ready." + _button("View it", url)
        return self._send(
            OutgoingEmail(
                to=to,
                subject=f"Your {what} is ready",
                html=_layout("Launch complete", body),
                text=f"Your {what} is ready: {url}",
            )
        )

    def send_post_reminder(self, to: str, platform: str, content: str, url: str) -> bool:
        body = (
            f"Your {html.escape(platform)} post is scheduled for now. Copy it below and post it."
            f"<pre style='white-space:pre-wrap;background:rgba(255,255,255,0.06);padding:16px;border-radius:8px'>"
            f"{html.escape(content)}</pre>" + _button("Open Launchpad", url)
        )
        return self._send(
            OutgoingEmail(
                to=to,
                subject=f"Time to post on {platform}",
                html=_layout("Launch window open", body),
                text=f"Time to post on {platform}:\n\n{content}\n\nLaunchpad: {url}",
            )
        )

    # Billing

    def send_payment_action(self, to: str, kind: str, url: str) -> bool:
        """kind: 'confirm' (the bank wants 3D Secure for a renewal) or 'failed' (card declined; Stripe retries)."""
        if kind == "confirm":
            title, subject = "Confirm your payment", "Your bank needs you to confirm your Notestack payment"
            body = ("Your bank asked for a quick confirmation (3D Secure) before your Notestack renewal can go "
                    "through. It takes a few seconds, and your plan stays active meanwhile.")
            cta = "Confirm payment"
        else:
            title, subject = "Your payment did not go through", "Your Notestack payment failed"
            body = ("We could not charge your card for Notestack. We will retry automatically over the next few "
                    "days; update your card to keep your plan without interruption.")
            cta = "Update payment"
        return self._send(
            OutgoingEmail(
                to=to,
                subject=subject,
                html=_layout(title, html.escape(body) + _button(cta, url)),
                text=f"{body}\n\n{cta}: {url}",
            )
        )

    # Internal alerts

    def send_alert(self, subject: str, text: str) -> bool:
        return self._send(
            OutgoingEmail(
                to=settings.alerts_email,
                subject=f"[Notestack alert] {subject}",
                html=_layout(subject, f"<pre style='white-space:pre-wrap'>{html.escape(text)}</pre>"),
                text=text,
            )
        )

    def send_support_escalation(self, *, from_email: str, name: str | None, plan: str | None, reason: str,
                                concern: str, page: str | None, transcript: list[tuple[str, str]]) -> bool:
        lines = [f"From: {name or 'unknown'} <{from_email}>", f"Plan: {plan or 'unknown'}", f"Reason: {reason}",
                 f"Page: {page or 'unknown'}", "", "Concern:", concern]
        if transcript:
            lines += ["", "Recent chat:"] + [f"{role}: {text}" for role, text in transcript]
        text = "\n".join(lines)
        return self._send(
            OutgoingEmail(
                to=settings.alerts_email,
                subject=f"[Notestack help] {reason}: {concern[:60]}",
                html=_layout("Help request", f"<pre style='white-space:pre-wrap'>{html.escape(text)}</pre>"),
                text=text,
                headers={"Reply-To": from_email},
            )
        )

    # Broadcast

    def send_blast_email(self, user_id: str, user_email: str, user_name: str | None, subject: str, body: str) -> bool:
        first = (user_name or "there").split(" ")[0]
        body_html = f"Hi {html.escape(first)},<br><br>" + html.escape(body).replace("\n", "<br>")
        return self._send(
            OutgoingEmail(
                to=user_email,
                subject=subject,
                html=_layout(subject, body_html, self._unsubscribe_footer(user_id)),
                text=f"Hi {first},\n\n{body}\n\nUnsubscribe: {self._unsubscribe_url(user_id)}",
                headers=self._list_unsubscribe(user_id),
            )
        )

    def _unsubscribe_url(self, user_id: str) -> str:
        return f"{settings.api_url}/api/unsubscribe?token={make_unsubscribe_token(user_id)}"

    def _unsubscribe_footer(self, user_id: str) -> str:
        return f'<a href="{self._unsubscribe_url(user_id)}" style="color:rgba(255,255,255,0.5)">Unsubscribe</a>'

    def _list_unsubscribe(self, user_id: str) -> dict[str, str]:
        return {
            "List-Unsubscribe": f"<{self._unsubscribe_url(user_id)}>",
            "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
        }


def _build() -> EmailService:
    if settings.email_provider == "resend" and settings.resend_api_key:
        return EmailService(ResendEmailProvider(settings.resend_api_key))
    if settings.email_provider == "resend":
        log.warning("EMAIL_PROVIDER=resend but RESEND_API_KEY is empty: emails are only logged, not sent")
    elif settings.email_provider != "console":
        log.warning("Unknown EMAIL_PROVIDER=%r (use resend or console): emails are only logged, not sent",
                    settings.email_provider)
    else:
        log.warning("EMAIL_PROVIDER=console: emails are only logged, not sent")
    return EmailService(ConsoleEmailProvider())


email_service = _build()