Skip to content

Commit ea70b19

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
remove gmail, improve links, README
1 parent 6d02c9b commit ea70b19

File tree

6 files changed

+79
-214
lines changed

6 files changed

+79
-214
lines changed

README.md

Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,77 +4,99 @@
44
<img src="media/logo.png" alt="ADVRider Notifier Logo" width="200">
55
</p>
66

7-
A secure, minimal Go service that notifies subscribers about new ADVRider forum posts via email. Designed for Google Cloud Run.
8-
9-
## Features
10-
11-
- **Multi-subscription support** - Users can subscribe to multiple threads with one email
12-
- **Secure token-based authentication** - 64-char random tokens prevent enumeration attacks
13-
- **Efficient thread fetching** - Each thread is fetched only once per check cycle, regardless of subscriber count
14-
- **Smart URL normalization** - Handles page numbers and anchors automatically
15-
- **Rate limiting** - 5 subscriptions per IP per hour
16-
- **Thread verification** - Validates threads exist before creating subscriptions
17-
- **Constant-time token comparison** - Prevents timing attacks
18-
- **Comprehensive security headers** - CSP, X-Frame-Options, X-Content-Type-Options
19-
- **Retry logic** - Exponential backoff with jitter for HTTP and Gmail API calls
20-
- **Structured logging** - JSON logs for Cloud Run with slog
21-
- **Graceful degradation** - Continues monitoring despite individual failures
22-
- **HTML email formatting** - Clean, responsive email templates
23-
24-
## Prerequisites
25-
26-
- Go 1.23 or later
27-
- Google Cloud Project with:
28-
- Cloud Run API enabled
29-
- Cloud Storage API enabled
30-
- Gmail API enabled
31-
- Service account with:
32-
- Gmail API access (https://www.googleapis.com/auth/gmail.send)
33-
- Cloud Storage access (Storage Object Admin role)
34-
- [ko](https://ko.build/) for deployment
7+
Email notifications for ADVRider threads. Built for Cloud Run, written in Go.
358

36-
## Environment Variables
9+
ADVRider's built-in notifications only email you once after your last visit to that thread. This keeps the party going by emailing every new post until you unsubscribe.
10+
11+
## What it does
3712

38-
| Variable | Description | Example |
39-
|----------|-------------|---------|
40-
| `STORAGE_BUCKET` | Cloud Storage bucket name for subscription data | `advrider-subscriptions` |
41-
| `BASE_URL` | Public URL of the deployed service | `https://advrider-notifier-xyz.run.app` |
42-
| `GOOGLE_CREDENTIALS_JSON` | Service account credentials JSON (optional, uses ADC if not set) | `{"type":"service_account",...}` |
43-
| `PORT` | HTTP server port (optional, defaults to 8080) | `8080` |
44-
| `LOCAL_STORAGE` | Local filesystem path for subscription data (optional, defaults to ./data) | `/var/tmp/advrider-notify` |
13+
Subscribe to ADVRider forum threads and get emails when new posts appear. That's it.
4514

46-
## Local Development
15+
- Adaptive polling (5min to 4hr based on thread activity)
16+
- Multiple threads per email address
17+
- Handles login-required forums gracefully (reports 403, doesn't crash)
18+
- Exponential backoff with retry on transient failures
19+
- Local dev mode with mocked email
4720

48-
### Build
21+
## Running it
4922

23+
**Local (filesystem + mock email):**
5024
```bash
51-
make build
25+
go run .
5226
```
5327

54-
### Run Tests
28+
Visit http://localhost:8080 and subscribe to a thread.
5529

30+
**Cloud Run (GCS + Brevo):**
5631
```bash
57-
make test
32+
export STORAGE_BUCKET=your-bucket-name
33+
export BASE_URL=https://your-service.run.app
34+
export BREVO_API_KEY=your-api-key
35+
ko apply -f service.yaml
5836
```
5937

60-
### Run Locally
38+
Requires a service account with Cloud Storage access and a Brevo API key.
6139

62-
The service automatically runs in local development mode with mock email when no `STORAGE_BUCKET` is set:
63-
64-
```bash
65-
# Simplest - just run it (uses ./data for storage, mocks email)
66-
go run .
40+
## Architecture
6741

68-
# Trigger a poll manually (POST only)
69-
curl -X POST http://localhost:8080/pollz
7042
```
43+
/ Homepage (subscribe form)
44+
/subscribe POST: Create subscription (verifies thread exists first)
45+
/manage?token=... View/delete subscriptions
46+
/pollz POST: Trigger immediate poll (no auth, rate limited by IP)
47+
```
48+
49+
Storage is either local filesystem (`./data`) or GCS (`STORAGE_BUCKET`).
50+
51+
Email via Brevo API. Falls back to mock in dev.
52+
53+
Each thread is scraped once per poll cycle regardless of subscriber count. Polling interval calculated per-thread using exponential backoff: `5min × 2^(hours_since_post / 3)`, capped at 4 hours.
54+
55+
## Security
7156

72-
## Deployment
57+
- Rate limiting: 5 subscriptions/hour per IP
58+
- Token-based subscription management (64-char random, constant-time comparison)
59+
- Thread limit: 20 per user (prevents resource exhaustion)
60+
- Email limit: 10 posts per notification (prevents abuse)
61+
- CSP, X-Frame-Options, X-Content-Type-Options headers
62+
- Thread verification before subscription (validates URL is actually an ADVRider thread)
7363

74-
The service uses [ko](https://ko.build/) for containerless deployment to Cloud Run.
64+
## Environment Variables
65+
66+
| Variable | Required | Description |
67+
|----------|----------|-------------|
68+
| `STORAGE_BUCKET` | Cloud only | GCS bucket for subscription data |
69+
| `BASE_URL` | Cloud only | Public URL (for manage links in emails) |
70+
| `BREVO_API_KEY` | Cloud only | Brevo API key for sending emails |
71+
| `SALT` | Required | Secret salt for token generation (set in GSM or env) |
72+
| `PORT` | Optional | HTTP port (default: 8080) |
73+
| `LOCAL_STORAGE` | Optional | Local storage path (default: ./data) |
74+
| `MAIL_FROM` | Optional | From email address (defaults to postmaster@domain) |
75+
| `MAIL_NAME` | Optional | From name (default: ADVRider Notifier) |
7576

76-
### Deploy
77+
## Development
7778

7879
```bash
79-
make deploy
80+
make build # Build binary
81+
make test # Run tests
82+
make lint # golangci-lint
83+
make deploy # Deploy to Cloud Run via ko
8084
```
85+
86+
Tests use real ADVRider HTML parsing (no mocks for scraper). Exponential backoff algorithm is tested for correctness across all activity levels.
87+
88+
## Email Templates
89+
90+
Dark mode support via `prefers-color-scheme`. WCAG AA compliant. Post numbers are clickable anchors to specific posts on specific pages.
91+
92+
Footer links: "View thread" (goes to last page + last post anchor) and "Manage" (token-authenticated subscription management).
93+
94+
## Why Go?
95+
96+
Fast cold starts on Cloud Run, trivial deploys with ko, stdlib has everything we need. No npm, no containers, no Dockerfile.
97+
98+
## License
99+
100+
Apache 2.0 - see LICENSE file
101+
102+
Built by [codeGROOVE llc](https://codegroove.dev)

email/gmail.go

Lines changed: 0 additions & 101 deletions
This file was deleted.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.2
55
require (
66
cloud.google.com/go/storage v1.48.0
77
github.com/PuerkitoBio/goquery v1.10.3
8+
github.com/codeGROOVE-dev/gsm v0.0.0-20251007153111-74e7bbe21f47
89
github.com/codeGROOVE-dev/retry v1.2.0
910
google.golang.org/api v0.214.0
1011
)
@@ -24,7 +25,6 @@ require (
2425
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
2526
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2627
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
27-
github.com/codeGROOVE-dev/gsm v0.0.0-20251007153111-74e7bbe21f47 // indirect
2828
github.com/envoyproxy/go-control-plane v0.13.0 // indirect
2929
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
3030
github.com/felixge/httpsnoop v1.0.4 // indirect

main.go

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import (
2121

2222
gcs "cloud.google.com/go/storage"
2323
"github.com/codeGROOVE-dev/gsm"
24-
"google.golang.org/api/gmail/v1"
25-
"google.golang.org/api/option"
2624
)
2725

2826
//go:embed media/*
@@ -248,68 +246,14 @@ func initEmailProvider(ctx context.Context, providerName string, logger *slog.Lo
248246
logger.Info("Initializing Brevo email provider", "from", fromAddr, "name", fromName)
249247
provider = email.NewBrevoProvider(apiKey, fromAddr, fromName, logger)
250248

251-
case "gmail":
252-
gmailService, err := initGmailService(ctx, logger)
253-
if err != nil {
254-
return nil, fmt.Errorf("initialize Gmail service: %w", err)
255-
}
256-
logger.Info("Initializing Gmail email provider", "from", fromAddr)
257-
provider = email.NewGmailProvider(gmailService, logger)
258-
259249
case "mock":
260250
logger.Info("Initializing mock email provider (no emails will be sent)", "from", fromAddr)
261251
provider = email.NewMockProvider(logger)
262252

263253
default:
264-
return nil, fmt.Errorf("unknown email provider: %s (valid options: brevo, gmail, mock)", providerName)
254+
return nil, fmt.Errorf("unknown email provider: %s (valid options: brevo, mock)", providerName)
265255
}
266256

267257
return email.New(provider, logger, baseURL, fromAddr), nil
268258
}
269259

270-
// isCloudRun checks if we're running in a GCP environment by querying the metadata server.
271-
func isCloudRun(ctx context.Context) bool {
272-
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
273-
defer cancel()
274-
275-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://metadata.google.internal/computeMetadata/v1/project/project-id", nil)
276-
if err != nil {
277-
return false
278-
}
279-
req.Header.Set("Metadata-Flavor", "Google")
280-
281-
client := &http.Client{Timeout: 2 * time.Second}
282-
resp, err := client.Do(req)
283-
if err != nil {
284-
return false
285-
}
286-
defer func() {
287-
if err := resp.Body.Close(); err != nil {
288-
// Silently ignore close errors for metadata check
289-
}
290-
}()
291-
292-
return resp.StatusCode == http.StatusOK
293-
}
294-
295-
func initGmailService(ctx context.Context, logger *slog.Logger) (*gmail.Service, error) {
296-
// Use gmail.GmailSendScope for send-only access (principle of least privilege)
297-
// This is more secure than using full Gmail access
298-
scope := option.WithScopes(gmail.GmailSendScope)
299-
300-
// Try explicit credentials first (for local development or specific use cases)
301-
credsJSON := secret(ctx, "GOOGLE_CREDENTIALS_JSON", logger)
302-
if credsJSON != "" {
303-
return gmail.NewService(ctx, option.WithCredentialsJSON([]byte(credsJSON)), scope)
304-
}
305-
306-
// If running in Cloud Run, use Application Default Credentials (ADC)
307-
// This automatically uses the service account
308-
// The service account needs Gmail API access (gmail.send scope)
309-
if isCloudRun(ctx) {
310-
return gmail.NewService(ctx, scope)
311-
}
312-
313-
// Not in Cloud Run and no explicit credentials
314-
return nil, errors.New("GOOGLE_CREDENTIALS_JSON required when not running in Cloud Run (set in environment or GSM)")
315-
}

server/tmpl/index.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
<button type="submit">Subscribe</button>
4040
</form>
4141
<div class="footer">
42-
Made with 🪿 by <a href="https://codegroove.com">codeGROOVE llc</a> • <a href="https://github.com/codeGROOVE-dev/advrider-notifier/">GitHub</a> • Contact <a href="https://advrider.com/f/members/helixblue.21963/">helixblue</a> with questions
42+
Made with 🪿 by <a href="https://codegroove.dev">codeGROOVE llc</a> • <a href="https://github.com/codeGROOVE-dev/advrider-notifier/">GitHub</a> • Contact <a href="https://advrider.com/f/members/helixblue.21963/">helixblue</a> with questions
4343
</div>
4444
</div>
4545
</body>

tmpl/index.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@
179179
<button type="submit">Subscribe</button>
180180
</form>
181181
<div class="footer">
182-
Made with 🪿 by <a href="https://codegroove.com">codeGROOVE llc</a> • Contact <a href="https://advrider.com/f/members/helixblue.21963/">helixblue</a> with questions
182+
Made with 🪿 by <a href="https://codegroove.dev">codeGROOVE llc</a> • Contact <a href="https://advrider.com/f/members/helixblue.21963/">helixblue</a> with questions
183183
</div>
184184
</div>
185185
</body>

0 commit comments

Comments
 (0)