The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package lib
6
7import (
8 "crypto/tls"
9 "fmt"
10 "net/smtp"
11 "os"
12 "time"
13)
14
15func SendEmail(to, subject, body string) error {
16 host := os.Getenv("SMTP_HOST")
17 port := os.Getenv("SMTP_PORT")
18 user := os.Getenv("SMTP_USER")
19 pass := os.Getenv("SMTP_PASS")
20 from := os.Getenv("SMTP_FROM")
21 if from == "" {
22 from = os.Getenv("MAIL_FROM")
23 }
24
25 if host == "" || from == "" {
26 fmt.Printf("[Email] Skipped sending to %s (no config)\nSubject: %s\nBody: %s\n", to, subject, body)
27 return nil
28 }
29
30 fromName := os.Getenv("SMTP_FROM_NAME")
31 if fromName == "" {
32 fromName = "boop.cat"
33 }
34
35 msg := []byte(fmt.Sprintf("From: %s <%s>\r\n"+
36 "To: %s\r\n"+
37 "Subject: %s\r\n"+
38 "MIME-Version: 1.0\r\n"+
39 "Content-Type: text/html; charset=\"UTF-8\"\r\n"+
40 "\r\n"+
41 "%s\r\n", fromName, from, to, subject, body))
42
43 addr := fmt.Sprintf("%s:%s", host, port)
44
45 if port == "465" || os.Getenv("SMTP_SECURE") == "true" {
46 tlsConfig := &tls.Config{
47 InsecureSkipVerify: false,
48 ServerName: host,
49 }
50
51 conn, err := tls.Dial("tcp", addr, tlsConfig)
52 if err != nil {
53 return fmt.Errorf("failed to dial tls: %w", err)
54 }
55 defer conn.Close()
56
57 c, err := smtp.NewClient(conn, host)
58 if err != nil {
59 return fmt.Errorf("failed to create smtp client: %w", err)
60 }
61 defer c.Quit()
62
63 if user != "" && pass != "" {
64 auth := smtp.PlainAuth("", user, pass, host)
65 if err = c.Auth(auth); err != nil {
66 return fmt.Errorf("auth failed: %w", err)
67 }
68 }
69
70 if err = c.Mail(from); err != nil {
71 return err
72 }
73 if err = c.Rcpt(to); err != nil {
74 return err
75 }
76 w, err := c.Data()
77 if err != nil {
78 return err
79 }
80 _, err = w.Write(msg)
81 if err != nil {
82 return err
83 }
84 err = w.Close()
85 if err != nil {
86 return err
87 }
88 return nil
89 }
90
91 auth := smtp.PlainAuth("", user, pass, host)
92 err := smtp.SendMail(addr, auth, from, []string{to}, msg)
93 if err != nil {
94 fmt.Printf("[Email] Failed to send: %v\n", err)
95 return err
96 }
97 return nil
98}
99
100func SendVerificationEmail(to, token, username string) error {
101 displayName := username
102 if displayName == "" {
103 displayName = "there"
104 }
105 url := fmt.Sprintf("%s/auth/verify-email?token=%s", os.Getenv("PUBLIC_URL"), token)
106 subject := "Verify your email - boop.cat"
107 body := buildEmailTemplate("Verify your email",
108 fmt.Sprintf("Hey %s! Click the button below to verify your email address and activate your account.", displayName),
109 "Verify Email", url,
110 "This link expires in 24 hours.")
111 return SendEmail(to, subject, body)
112}
113
114func SendPasswordResetEmail(to, token, username string) error {
115 displayName := username
116 if displayName == "" {
117 displayName = "there"
118 }
119 url := fmt.Sprintf("%s/reset-password?token=%s", os.Getenv("PUBLIC_URL"), token)
120 subject := "Reset your password - boop.cat"
121 body := buildEmailTemplate("Reset your password",
122 fmt.Sprintf("Hey %s! Someone requested a password reset for your account. Click the button below to set a new password.", displayName),
123 "Reset Password", url,
124 "This link expires in 1 hour. If you didn't request this, ignore this email.")
125 return SendEmail(to, subject, body)
126}
127
128func buildEmailTemplate(heading, message, buttonText, buttonURL, footer string) string {
129 brandName := "boop.cat"
130 return fmt.Sprintf(`<!DOCTYPE html>
131<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
132<head>
133 <meta charset="UTF-8">
134 <meta name="viewport" content="width=device-width, initial-scale=1.0">
135 <meta http-equiv="X-UA-Compatible" content="IE=edge">
136 <meta name="color-scheme" content="light dark">
137 <meta name="supported-color-schemes" content="light dark">
138 <title>%s</title>
139 <!--[if mso]>
140 <style type="text/css">
141 body, table, td, p, a, h1 {font-family: Arial, sans-serif !important;}
142 </style>
143 <![endif]-->
144 <style>
145 :root { color-scheme: light dark; }
146 body { margin: 0; padding: 0; }
147 @media (prefers-color-scheme: dark) {
148 .email-bg { background-color: #1a1a2e !important; }
149 .email-card { background-color: #16213e !important; }
150 .email-title { color: #f1f5f9 !important; }
151 .email-text { color: #cbd5e1 !important; }
152 .email-muted { color: #94a3b8 !important; }
153 .email-link { color: #60a5fa !important; }
154 .email-divider { border-color: #334155 !important; }
155 }
156 </style>
157</head>
158<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased;">
159 <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" class="email-bg" style="background-color: #f1f5f9;">
160 <tr>
161 <td align="center" style="padding: 40px 16px;">
162 <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width: 480px;">
163 <!-- Logo -->
164 <tr>
165 <td align="center" style="padding-bottom: 24px;">
166 <span class="email-title" style="font-size: 20px; font-weight: 700; color: #0f172a;">%s</span>
167 </td>
168 </tr>
169 <!-- Card -->
170 <tr>
171 <td>
172 <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" class="email-card" style="background-color: #ffffff; border-radius: 12px;">
173 <tr>
174 <td style="padding: 32px 28px;">
175 <h1 class="email-title" style="margin: 0 0 8px; font-size: 22px; font-weight: 700; color: #0f172a; text-align: center;">%s</h1>
176 <p class="email-text" style="margin: 0 0 24px; font-size: 15px; color: #475569; text-align: center; line-height: 1.6;">%s</p>
177 <!-- Button -->
178 <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0">
179 <tr>
180 <td align="center" style="padding: 4px 0;">
181 <a href="%s" target="_blank" style="display: inline-block; padding: 12px 28px; background-color: #2563eb; color: #ffffff; font-size: 15px; font-weight: 600; text-decoration: none; border-radius: 8px;">%s</a>
182 </td>
183 </tr>
184 </table>
185 <p class="email-text" style="margin: 24px 0 0; font-size: 13px; color: #64748b; text-align: center; line-height: 1.6;">
186 Or copy this link into your browser:<br>
187 <a href="%s" class="email-link" style="color: #2563eb; word-break: break-all; text-decoration: underline;">%s</a>
188 </p>
189 <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 24px;">
190 <tr>
191 <td class="email-divider" style="border-top: 1px solid #e2e8f0; padding-top: 16px;">
192 <p class="email-muted" style="margin: 0; font-size: 12px; color: #94a3b8; text-align: center;">%s</p>
193 </td>
194 </tr>
195 </table>
196 </td>
197 </tr>
198 </table>
199 </td>
200 </tr>
201 <!-- Footer -->
202 <tr>
203 <td align="center" style="padding-top: 24px;">
204 <p class="email-muted" style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.6;">
205 © %d %s
206 </p>
207 </td>
208 </tr>
209 </table>
210 </td>
211 </tr>
212 </table>
213</body>
214</html>`, heading, brandName, heading, message, buttonURL, buttonText, buttonURL, buttonURL, footer, time.Now().Year(), brandName)
215}