| 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 |