Go · 9062 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package notif
4
5 import (
6 "context"
7 "crypto/hmac"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "html"
14 "strconv"
15 "strings"
16
17 "github.com/jackc/pgx/v5"
18 "github.com/jackc/pgx/v5/pgtype"
19 "github.com/jackc/pgx/v5/pgxpool"
20
21 "github.com/tenseleyFlow/shithub/internal/auth/email"
22 notifdb "github.com/tenseleyFlow/shithub/internal/notif/sqlc"
23 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
24 )
25
26 // sendNotificationEmail renders + sends one notification email and
27 // records it in notification_email_log. Idempotency is the
28 // dampener's job, not ours; we send what we're told to send.
29 //
30 // The email body is intentionally minimal in v1: a plaintext line
31 // summarising the reason + a link to the thread. Per-kind HTML
32 // templates land in a follow-up commit (or the next sprint that
33 // touches the email surface). The List-Unsubscribe URL is wired
34 // up so the standards-compliant bits (RFC 8058) are in place from
35 // day one.
36 func sendNotificationEmail(
37 ctx context.Context, deps Deps,
38 recipientID int64,
39 notif notifdb.Notification,
40 reason Reason,
41 threadKind notifdb.NullNotificationThreadKind,
42 threadID int64,
43 ) error {
44 if deps.EmailSender == nil {
45 return nil
46 }
47 uq := usersdb.New()
48 user, err := uq.GetUserByID(ctx, deps.Pool, recipientID)
49 if err != nil {
50 return fmt.Errorf("load recipient: %w", err)
51 }
52 if !user.PrimaryEmailID.Valid {
53 // No verified primary; nothing to send to.
54 return nil
55 }
56 em, err := uq.GetUserEmailByID(ctx, deps.Pool, user.PrimaryEmailID.Int64)
57 if err != nil || !em.Verified {
58 return nil
59 }
60
61 threadURL := buildThreadURL(deps.BaseURL, ctx, deps.Pool, notif)
62 subject := buildSubject(deps.SiteName, notif, reason)
63 plain := buildPlainBody(deps.SiteName, notif, reason, threadURL)
64 htmlBody := buildHTMLBody(deps.SiteName, notif, reason, threadURL)
65
66 // The List-Unsubscribe URL is computed but not yet wired into
67 // the Sender — the existing email.Message shape (S05) doesn't
68 // carry arbitrary headers. Adding a `Headers map[string]string`
69 // is a follow-up that ripples through the SMTP / Postmark
70 // backends; tracked in the S29 status block as deferred. The
71 // URL is still useful as a body-footer fallback.
72 unsubURL := unsubscribeURL(deps.BaseURL, deps.UnsubscribeKey, recipientID,
73 threadKind.Valid, threadKindString(threadKind), threadID)
74 plain += "\nUnsubscribe: " + unsubURL + "\n"
75
76 if err := deps.EmailSender.Send(ctx, email.Message{
77 From: deps.EmailFrom,
78 To: string(em.Email),
79 Subject: subject,
80 HTML: htmlBody,
81 Text: plain,
82 }); err != nil {
83 return fmt.Errorf("send: %w", err)
84 }
85
86 // Log the send so the dampener can count it next time.
87 if err := notifdb.New().InsertEmailLog(ctx, deps.Pool, notifdb.InsertEmailLogParams{
88 RecipientUserID: recipientID,
89 NotificationID: pgInt(notif.ID),
90 ThreadKind: threadKind,
91 ThreadID: pgInt(threadID),
92 MessageID: pgString(""),
93 }); err != nil {
94 // Non-fatal — the email already went out. Log loudly so
95 // the operator notices a logging-side regression.
96 if deps.Logger != nil {
97 deps.Logger.WarnContext(ctx, "email log insert failed",
98 "recipient", recipientID, "error", err)
99 }
100 }
101 return nil
102 }
103
104 // emailPrefOn reads the user's per-key boolean preference. Defaults
105 // to true when no row exists (matches the spec's "Email all
106 // participating threads master toggle, default on").
107 func emailPrefOn(ctx context.Context, pool *pgxpool.Pool, userID int64, key string) (bool, error) {
108 var raw json.RawMessage
109 err := pool.QueryRow(
110 ctx,
111 `SELECT value FROM user_notification_prefs WHERE user_id = $1 AND key = $2`,
112 userID, key,
113 ).Scan(&raw)
114 if err != nil {
115 if errors.Is(err, pgx.ErrNoRows) {
116 return true, nil
117 }
118 return true, err
119 }
120 // Stored as JSON `true` or `false`.
121 s := strings.TrimSpace(string(raw))
122 return s == "true", nil
123 }
124
125 // unsubscribeURL builds an HMAC-signed one-click unsubscribe link.
126 // The URL embeds (recipient_id, thread_kind, thread_id, sig) so
127 // the handler can verify and act without a session cookie. Key
128 // rotation invalidates outstanding links — operators are expected
129 // to keep the key stable.
130 func unsubscribeURL(baseURL string, key []byte, recipientID int64, hasThread bool, threadKind string, threadID int64) string {
131 if !hasThread {
132 // No thread → can't unsubscribe at the per-thread level.
133 // The footer's link should point at the user's email-prefs
134 // page instead. Return that path; the email-template
135 // renderer can swap it in.
136 return strings.TrimRight(baseURL, "/") + "/settings/notifications"
137 }
138 rec := strconv.FormatInt(recipientID, 10)
139 tid := strconv.FormatInt(threadID, 10)
140 payload := rec + ":" + threadKind + ":" + tid
141 sig := HMACSig(key, payload)
142 return strings.TrimRight(baseURL, "/") + "/notifications/unsubscribe?u=" + rec +
143 "&tk=" + threadKind + "&ti=" + tid + "&sig=" + sig
144 }
145
146 // VerifyUnsubscribe checks the HMAC. Returns true when the
147 // signature matches the supplied (recipient, thread) tuple.
148 func VerifyUnsubscribe(key []byte, recipientID int64, threadKind string, threadID int64, sig string) bool {
149 rec := strconv.FormatInt(recipientID, 10)
150 tid := strconv.FormatInt(threadID, 10)
151 payload := rec + ":" + threadKind + ":" + tid
152 want := HMACSig(key, payload)
153 return hmac.Equal([]byte(sig), []byte(want))
154 }
155
156 // HMACSig signs a payload with the unsubscribe HMAC key. Exposed so
157 // tests can build verifying signatures without re-implementing the
158 // canonical wire format.
159 func HMACSig(key []byte, payload string) string {
160 mac := hmac.New(sha256.New, key)
161 mac.Write([]byte(payload))
162 return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
163 }
164
165 func threadKindString(k notifdb.NullNotificationThreadKind) string {
166 if !k.Valid {
167 return ""
168 }
169 return string(k.NotificationThreadKind)
170 }
171
172 func buildSubject(site string, notif notifdb.Notification, reason Reason) string {
173 prefix := site
174 if prefix == "" {
175 prefix = "shithub"
176 }
177 return "[" + prefix + "] " + string(reason) + ": " + notif.Kind
178 }
179
180 func buildPlainBody(site string, notif notifdb.Notification, reason Reason, link string) string {
181 if site == "" {
182 site = "shithub"
183 }
184 var b strings.Builder
185 b.WriteString("You're being notified on " + site + ".\n\n")
186 b.WriteString("Reason: " + string(reason) + "\n")
187 b.WriteString("Event: " + notif.Kind + "\n")
188 if link != "" {
189 b.WriteString("\n" + link + "\n")
190 }
191 b.WriteString("\n— " + site + "\n")
192 return b.String()
193 }
194
195 // buildHTMLBody is the deliberately-minimal HTML mirror of the plain
196 // body. Per-kind templates land in a follow-up; today the layout is
197 // site / reason / event / link, all escaped. No CSS, no images — most
198 // MUAs render this fine and the simpler the markup, the lower the
199 // spam-score risk.
200 func buildHTMLBody(site string, notif notifdb.Notification, reason Reason, link string) string {
201 if site == "" {
202 site = "shithub"
203 }
204 var b strings.Builder
205 b.WriteString("<p>You're being notified on ")
206 b.WriteString(html.EscapeString(site))
207 b.WriteString(".</p>\n")
208 b.WriteString("<p><strong>Reason:</strong> ")
209 b.WriteString(html.EscapeString(string(reason)))
210 b.WriteString("<br><strong>Event:</strong> ")
211 b.WriteString(html.EscapeString(notif.Kind))
212 b.WriteString("</p>\n")
213 if link != "" {
214 b.WriteString(`<p><a href="`)
215 b.WriteString(html.EscapeString(link))
216 b.WriteString(`">`)
217 b.WriteString(html.EscapeString(link))
218 b.WriteString("</a></p>\n")
219 }
220 b.WriteString("<p>— ")
221 b.WriteString(html.EscapeString(site))
222 b.WriteString("</p>\n")
223 return b.String()
224 }
225
226 // buildThreadURL composes a canonical URL for the thread the
227 // notification points at. Best-effort: when we can't resolve the
228 // repo / number we fall back to the notification's inbox row URL.
229 func buildThreadURL(baseURL string, ctx context.Context, pool *pgxpool.Pool, notif notifdb.Notification) string {
230 if !notif.RepoID.Valid || !notif.ThreadID.Valid || !notif.ThreadKind.Valid {
231 return strings.TrimRight(baseURL, "/") + "/notifications"
232 }
233 row, err := notifdb.New().GetNotification(ctx, pool, notif.ID)
234 _ = row
235 if err != nil {
236 return strings.TrimRight(baseURL, "/") + "/notifications"
237 }
238 // Resolve owner + repo + number with a small ad-hoc query.
239 var ownerName, repoName string
240 var number int64
241 err = pool.QueryRow(ctx, `
242 SELECT u.username, r.name, i.number
243 FROM repos r
244 JOIN users u ON u.id = r.owner_user_id
245 LEFT JOIN issues i ON i.id = $1
246 WHERE r.id = $2
247 `, notif.ThreadID.Int64, notif.RepoID.Int64).Scan(&ownerName, &repoName, &number)
248 if err != nil || number == 0 {
249 return strings.TrimRight(baseURL, "/") + "/notifications"
250 }
251 segment := "issues"
252 if notif.ThreadKind.NotificationThreadKind == notifdb.NotificationThreadKindPr {
253 segment = "pulls"
254 }
255 return strings.TrimRight(baseURL, "/") + "/" + ownerName + "/" + repoName + "/" + segment + "/" + strconv.FormatInt(number, 10)
256 }
257
258 func pgInt(v int64) pgtype.Int8 { return pgtype.Int8{Int64: v, Valid: v != 0} }
259 func pgString(s string) pgtype.Text {
260 return pgtype.Text{String: s, Valid: s != ""}
261 }
262