Skip to content

Commit 60a3f0f

Browse files
James McHughclaude
andcommitted
Add privacy, terms, and contact pages with SMTP-backed form
New public routes /privacy, /terms, /contact rendered with the existing landing.css shell so they share a coherent header/footer with /login. Footer of the landing page now links to all three plus Sign in. The contact page is a form rather than a mailto: link so the email address isn't exposed to scrapers. Submissions are validated (5-5000 chars, optional reply-email), guarded by an offscreen honeypot and a per-IP 3/hour rate limit, then sent to Gmail via stdlib net/smtp using an app password. Reply-To is set to the visitor's email so replies reach them. If SMTP_USER/SMTP_PASS are blank (local dev) the handler logs the submission instead of sending so the form is testable without credentials. Also tightens 8 slog calls to match the codebase's "msg = short noun phrase, state implied by level, structured data in kv pairs" style. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d68dedd commit 60a3f0f

9 files changed

Lines changed: 659 additions & 6 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ SESSION_KEY=replace_with_64_random_hex_chars
1414
# Optional: shortcut to bypass Google OAuth in local dev. If set,
1515
# /auth/login auto-creates this user and signs them in. NEVER set in prod.
1616
DEV_USER_EMAIL=
17+
18+
# Gmail SMTP for the /contact form. Generate an app password at
19+
# https://myaccount.google.com/apppasswords (2-Step Verification must be on).
20+
# Leave SMTP_USER/SMTP_PASS blank in local dev to log submissions to stdout
21+
# instead of sending. CONTACT_TO_EMAIL is optional - defaults to SMTP_USER.
22+
SMTP_USER=
23+
SMTP_PASS=
24+
CONTACT_TO_EMAIL=

auth.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ func handleLoginPage(w http.ResponseWriter, r *http.Request) {
117117
}
118118
}
119119

120+
func renderMarketingPage(w http.ResponseWriter, name string) {
121+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
122+
if err := templates.ExecuteTemplate(w, name, nil); err != nil {
123+
slog.Error("marketing template", "name", name, "error", err)
124+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
125+
}
126+
}
127+
128+
func handlePrivacyPage(w http.ResponseWriter, r *http.Request) {
129+
renderMarketingPage(w, "privacy.html")
130+
}
131+
132+
func handleTermsPage(w http.ResponseWriter, r *http.Request) {
133+
renderMarketingPage(w, "terms.html")
134+
}
135+
120136
func handleAuthLogin(w http.ResponseWriter, r *http.Request) {
121137
// Local-dev shortcut: skip Google entirely, log in as DEV_USER_EMAIL.
122138
if oauthCfg.devEmail != "" {
@@ -215,7 +231,7 @@ func handleAuthCallback(w http.ResponseWriter, r *http.Request) {
215231

216232
user, err := upsertUser(r.Context(), claims.Sub, claims.Email, claims.Name)
217233
if err != nil {
218-
slog.Error("upsertUser", "error", err)
234+
slog.Error("upsert user", "error", err)
219235
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
220236
return
221237
}

contact.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net"
7+
"net/http"
8+
"net/smtp"
9+
"os"
10+
"strings"
11+
"time"
12+
)
13+
14+
// 3 submissions per hour per IP. Sits on top of the global 120/min limiter.
15+
var contactLimiter = newRateLimiter(3, time.Hour)
16+
17+
type contactPageData struct {
18+
Sent bool
19+
Error string
20+
}
21+
22+
func handleContactPage(w http.ResponseWriter, r *http.Request) {
23+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
24+
data := contactPageData{
25+
Sent: r.URL.Query().Get("sent") == "1",
26+
Error: r.URL.Query().Get("error"),
27+
}
28+
if err := templates.ExecuteTemplate(w, "contact.html", data); err != nil {
29+
slog.Error("contact template", "error", err)
30+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
31+
}
32+
}
33+
34+
func handleContactSubmit(w http.ResponseWriter, r *http.Request) {
35+
ip, _, err := net.SplitHostPort(r.RemoteAddr)
36+
if err != nil {
37+
ip = r.RemoteAddr
38+
}
39+
40+
if err := r.ParseForm(); err != nil {
41+
http.Redirect(w, r, "/contact?error=invalid", http.StatusSeeOther)
42+
return
43+
}
44+
45+
// Honeypot: real users leave this blank; bots fill it in.
46+
if r.PostForm.Get("website") != "" {
47+
slog.Info("contact honeypot", "ip", ip)
48+
// Pretend success so bots don't learn the trick.
49+
http.Redirect(w, r, "/contact?sent=1", http.StatusSeeOther)
50+
return
51+
}
52+
53+
if !contactLimiter.allow(ip) {
54+
slog.Warn("contact rate limit exceeded", "ip", ip)
55+
http.Redirect(w, r, "/contact?error=rate", http.StatusSeeOther)
56+
return
57+
}
58+
59+
message := strings.TrimSpace(r.PostForm.Get("message"))
60+
replyEmail := strings.TrimSpace(r.PostForm.Get("reply_email"))
61+
62+
if len(message) < 5 || len(message) > 5000 {
63+
http.Redirect(w, r, "/contact?error=invalid", http.StatusSeeOther)
64+
return
65+
}
66+
if replyEmail != "" && !looksLikeEmail(replyEmail) {
67+
http.Redirect(w, r, "/contact?error=invalid", http.StatusSeeOther)
68+
return
69+
}
70+
71+
if err := sendContactEmail(replyEmail, message, ip); err != nil {
72+
slog.Error("contact send", "error", err, "ip", ip)
73+
http.Redirect(w, r, "/contact?error=smtp", http.StatusSeeOther)
74+
return
75+
}
76+
77+
http.Redirect(w, r, "/contact?sent=1", http.StatusSeeOther)
78+
}
79+
80+
func looksLikeEmail(s string) bool {
81+
at := strings.IndexByte(s, '@')
82+
if at < 1 || at == len(s)-1 {
83+
return false
84+
}
85+
if !strings.Contains(s[at+1:], ".") {
86+
return false
87+
}
88+
if strings.ContainsAny(s, " \t\r\n<>") {
89+
return false
90+
}
91+
return true
92+
}
93+
94+
func sendContactEmail(replyEmail, message, ip string) error {
95+
smtpUser := os.Getenv("SMTP_USER")
96+
smtpPass := os.Getenv("SMTP_PASS")
97+
to := os.Getenv("CONTACT_TO_EMAIL")
98+
if to == "" {
99+
to = smtpUser
100+
}
101+
102+
// Local-dev shortcut: if SMTP isn't configured, log the message so the
103+
// form is testable end-to-end without credentials.
104+
if smtpUser == "" || smtpPass == "" {
105+
slog.Info("contact form skipped",
106+
"reason", "smtp not configured",
107+
"reply_email", replyEmail, "ip", ip, "message", message)
108+
return nil
109+
}
110+
111+
subject := "Train contact"
112+
if preview := firstLine(message); preview != "" {
113+
subject = "Train contact: " + truncate(preview, 60)
114+
}
115+
116+
replyTo := smtpUser
117+
if replyEmail != "" {
118+
replyTo = replyEmail
119+
}
120+
121+
body := fmt.Sprintf(
122+
"Reply email: %s\nIP: %s\nSubmitted: %s\n\n%s\n",
123+
valueOrDash(replyEmail), ip, time.Now().UTC().Format(time.RFC3339), message,
124+
)
125+
126+
msg := []byte(strings.Join([]string{
127+
"From: Train <" + smtpUser + ">",
128+
"To: " + to,
129+
"Reply-To: " + replyTo,
130+
"Subject: " + subject,
131+
"MIME-Version: 1.0",
132+
"Content-Type: text/plain; charset=UTF-8",
133+
"",
134+
body,
135+
}, "\r\n"))
136+
137+
auth := smtp.PlainAuth("", smtpUser, smtpPass, "smtp.gmail.com")
138+
return smtp.SendMail("smtp.gmail.com:587", auth, smtpUser, []string{to}, msg)
139+
}
140+
141+
func firstLine(s string) string {
142+
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
143+
return strings.TrimSpace(s[:i])
144+
}
145+
return strings.TrimSpace(s)
146+
}
147+
148+
func truncate(s string, n int) string {
149+
if len(s) <= n {
150+
return s
151+
}
152+
return s[:n] + "..."
153+
}
154+
155+
func valueOrDash(s string) string {
156+
if s == "" {
157+
return "(not provided)"
158+
}
159+
return s
160+
}

static/landing.css

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,137 @@ a { color: inherit; text-decoration: none; }
918918
}
919919
.lp-foot a { color: var(--lp-ink-soft); }
920920
.lp-foot a:hover { color: var(--lp-ink); }
921+
.lp-foot__links {
922+
display: flex;
923+
flex-wrap: wrap;
924+
gap: 1.25rem;
925+
}
926+
927+
/* ========== prose pages (privacy / terms / contact) ========== */
928+
.lp-prose {
929+
max-width: 44rem;
930+
padding-top: 3rem;
931+
padding-bottom: 4.5rem;
932+
color: var(--lp-ink);
933+
}
934+
.lp-prose h1 {
935+
font-size: 2rem;
936+
letter-spacing: -0.02em;
937+
margin: 0 0 0.4rem;
938+
}
939+
@media (min-width: 720px) {
940+
.lp-prose h1 { font-size: 2.4rem; }
941+
}
942+
.lp-prose h2 {
943+
font-size: 1.1rem;
944+
letter-spacing: 0;
945+
margin: 2rem 0 0.4rem;
946+
color: var(--lp-ink);
947+
}
948+
.lp-prose p,
949+
.lp-prose li {
950+
color: var(--lp-ink-soft);
951+
line-height: 1.65;
952+
font-size: 1rem;
953+
}
954+
.lp-prose ul {
955+
padding-left: 1.25rem;
956+
margin: 0.4rem 0 0.6rem;
957+
}
958+
.lp-prose li { margin: 0.2rem 0; }
959+
.lp-prose b { color: var(--lp-ink); font-weight: 600; }
960+
.lp-prose a {
961+
color: var(--lp-ink);
962+
border-bottom: 1px solid var(--lp-line);
963+
}
964+
.lp-prose a:hover { border-bottom-color: var(--lp-ink-soft); }
965+
.lp-prose .updated {
966+
color: var(--lp-ink-mute);
967+
font-size: 0.85rem;
968+
margin: 0 0 1.5rem;
969+
}
970+
.lp-contact__email {
971+
font-size: 1.25rem;
972+
margin: 1.25rem 0 1.5rem;
973+
}
974+
975+
/* ========== alert banners (form flash messages) ========== */
976+
.lp-alert {
977+
margin: 1.5rem 0;
978+
padding: 0.85rem 1rem;
979+
border: 1px solid var(--lp-line);
980+
border-left-width: 4px;
981+
border-radius: 4px;
982+
font-size: 0.95rem;
983+
background: var(--lp-card);
984+
}
985+
.lp-alert--ok { border-left-color: var(--lp-green); }
986+
.lp-alert--err { border-left-color: var(--lp-red); }
987+
988+
/* ========== contact form ========== */
989+
.lp-form {
990+
display: flex;
991+
flex-direction: column;
992+
gap: 1rem;
993+
margin-top: 1.5rem;
994+
}
995+
.lp-form__field {
996+
display: flex;
997+
flex-direction: column;
998+
gap: 0.35rem;
999+
}
1000+
.lp-form__label {
1001+
font-size: 0.85rem;
1002+
color: var(--lp-ink-soft);
1003+
letter-spacing: 0.02em;
1004+
}
1005+
.lp-form__label em {
1006+
font-style: normal;
1007+
color: var(--lp-ink-mute);
1008+
font-weight: normal;
1009+
}
1010+
.lp-form input[type="email"],
1011+
.lp-form input[type="text"],
1012+
.lp-form textarea {
1013+
font: inherit;
1014+
font-size: 1rem;
1015+
color: var(--lp-ink);
1016+
background: var(--lp-card);
1017+
border: 1px solid var(--lp-line);
1018+
border-radius: 4px;
1019+
padding: 0.7rem 0.85rem;
1020+
width: 100%;
1021+
outline: none;
1022+
transition: border-color 120ms;
1023+
-webkit-appearance: none;
1024+
appearance: none;
1025+
}
1026+
.lp-form textarea {
1027+
resize: vertical;
1028+
min-height: 8rem;
1029+
line-height: 1.5;
1030+
font-family: inherit;
1031+
}
1032+
.lp-form input:focus,
1033+
.lp-form textarea:focus {
1034+
border-color: var(--lp-blue-2);
1035+
}
1036+
.lp-form input::placeholder,
1037+
.lp-form textarea::placeholder {
1038+
color: var(--lp-ink-mute);
1039+
}
1040+
.lp-form__submit {
1041+
align-self: flex-start;
1042+
margin-top: 0.4rem;
1043+
}
1044+
/* honeypot - kept in DOM but invisible to humans */
1045+
.lp-form__hp {
1046+
position: absolute;
1047+
left: -10000px;
1048+
width: 1px;
1049+
height: 1px;
1050+
overflow: hidden;
1051+
}
9211052

9221053
/* ========== motion preference ========== */
9231054
@media (prefers-reduced-motion: reduce) {

0 commit comments

Comments
 (0)