rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm

feat: switch to extension links

dunkirk.sh 63aacf78 72d4a4e8

verified
+236 -135
+10 -4
email/render.go
··· 47 47 } 48 48 } 49 49 50 - func RenderDigest(data *DigestData, inline bool) (html string, text string, err error) { 50 + func RenderDigest(data *DigestData, inline bool, daysUntilExpiry int, showUrgentBanner, showWarningBanner bool) (html string, text string, err error) { 51 51 tmplData := struct { 52 52 *DigestData 53 - Inline bool 53 + Inline bool 54 + DaysUntilExpiry int 55 + ShowUrgentBanner bool 56 + ShowWarningBanner bool 54 57 }{ 55 - DigestData: data, 56 - Inline: inline, 58 + DigestData: data, 59 + Inline: inline, 60 + DaysUntilExpiry: daysUntilExpiry, 61 + ShowUrgentBanner: showUrgentBanner, 62 + ShowWarningBanner: showWarningBanner, 57 63 } 58 64 59 65 var htmlBuf, textBuf bytes.Buffer
+13 -14
email/send.go
··· 150 150 return client.Quit() 151 151 } 152 152 153 - func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken, dashboardURL, trackingToken string) error { 153 + func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken, dashboardURL, keepAliveURL string) error { 154 154 addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port)) 155 155 156 156 boundary := "==herald-boundary-a1b2c3d4e5f6==" 157 157 158 - // Add footer with unsubscribe and dashboard links 158 + // Add footer with keep-alive, unsubscribe, and dashboard links 159 159 var htmlFooter strings.Builder 160 160 var textFooter strings.Builder 161 161 162 - if unsubToken != "" || dashboardURL != "" { 162 + if keepAliveURL != "" || unsubToken != "" || dashboardURL != "" { 163 163 htmlFooter.WriteString(`<hr><p style="font-size: 12px; color: #666;">`) 164 164 textFooter.WriteString("\n\n---\n") 165 165 166 + if keepAliveURL != "" { 167 + htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">keep this digest active</a>`, keepAliveURL)) 168 + textFooter.WriteString(fmt.Sprintf("keep this digest active: %s\n", keepAliveURL)) 169 + } 170 + 166 171 if dashboardURL != "" { 172 + if keepAliveURL != "" { 173 + htmlFooter.WriteString(" • ") 174 + } 167 175 htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">profile</a>`, dashboardURL)) 168 176 textFooter.WriteString(fmt.Sprintf("profile: %s\n", dashboardURL)) 169 177 } 170 178 171 179 if unsubToken != "" { 172 180 unsubURL := m.unsubBaseURL + "/unsubscribe/" + unsubToken 173 - if dashboardURL != "" { 181 + if dashboardURL != "" || keepAliveURL != "" { 174 182 htmlFooter.WriteString(" • ") 175 - textFooter.WriteString("") 176 183 } 177 184 htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">unsubscribe</a>`, unsubURL)) 178 185 textFooter.WriteString(fmt.Sprintf("unsubscribe: %s\n", unsubURL)) ··· 223 230 msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) 224 231 msg.WriteString("Content-Type: text/html; charset=utf-8\r\n") 225 232 msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") 226 - 227 - // Add tracking pixel if token provided 228 - htmlBodyWithTracking := htmlBody 229 - if trackingToken != "" { 230 - trackingURL := m.unsubBaseURL + "/t/" + trackingToken + ".gif" 231 - htmlBodyWithTracking = htmlBody + fmt.Sprintf(`<img src="%s" width="1" height="1" alt="" style="display:none;">`, trackingURL) 232 - } 233 - 234 - htmlQP := encodeQuotedPrintable(htmlBodyWithTracking) 233 + htmlQP := encodeQuotedPrintable(htmlBody) 235 234 msg.WriteString(htmlQP) 236 235 msg.WriteString("\r\n") 237 236
+73 -39
email/templates/digest.html
··· 1 1 <!DOCTYPE html> 2 2 <html> 3 + 3 4 <head> 4 - <meta charset="utf-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1"> 6 - <style> 7 - img { 8 - max-width: 100%; 9 - height: auto; 10 - } 11 - .feeds { 12 - max-width: 100%; 13 - } 14 - </style> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1"> 7 + <style> 8 + img { 9 + max-width: 100%; 10 + height: auto; 11 + } 12 + 13 + .feeds { 14 + max-width: 100%; 15 + } 16 + 17 + .expiry-banner { 18 + padding: 15px; 19 + margin-bottom: 20px; 20 + border-radius: 4px; 21 + font-size: 14px; 22 + } 23 + 24 + .expiry-urgent { 25 + background-color: #fee; 26 + border: 2px solid #f44; 27 + color: #c00; 28 + } 29 + 30 + .expiry-warning { 31 + background-color: #ffc; 32 + border: 1px solid #fc0; 33 + color: #840; 34 + } 35 + </style> 15 36 </head> 37 + 16 38 <body> 17 - <div class="feeds"> 18 - {{range .FeedGroups}} 19 - <div style="margin-bottom: 10px;"> 20 - <h1 style="margin-bottom: 3px;"><a href="{{.FeedURL}}">{{.FeedName}}</a></h1> 21 - </div> 39 + {{if .ShowUrgentBanner}} 40 + <div class="expiry-banner expiry-urgent"> 41 + Your digest expires in {{.DaysUntilExpiry}} days. Click "keep this digest active" below to continue receiving 42 + updates or your feed will be deactivated. 43 + </div> 44 + {{else if .ShowWarningBanner}} 45 + <div class="expiry-banner expiry-warning"> 46 + Your digest expires in {{.DaysUntilExpiry}} days. Click "keep this digest active" below to 47 + extend it. 48 + </div> 49 + {{end}} 50 + <div class="feeds"> 51 + {{range .FeedGroups}} 52 + <div style="margin-bottom: 10px;"> 53 + <h1 style="margin-bottom: 3px;"><a href="{{.FeedURL}}">{{.FeedName}}</a></h1> 54 + </div> 22 55 23 - <div class="summary"> 24 - <h2>Entries</h2> 25 - {{range .Items}} 26 - <ul> 27 - <li><a href="{{.Link}}">{{.Title}}</a></li> 28 - </ul> 29 - {{end}} 30 - </div> 56 + <div class="summary"> 57 + <h2>Entries</h2> 58 + {{range .Items}} 59 + <ul> 60 + <li><a href="{{.Link}}">{{.Title}}</a></li> 61 + </ul> 62 + {{end}} 63 + </div> 31 64 32 - {{if $.Inline}} 33 - <div> 34 - {{range .Items}} 35 - <div> 36 - <h1><a href="{{.Link}}">{{.Title}}</a></h1> 37 - <div>{{.Content}}</div> 38 - </div> 39 - <hr /> 40 - {{end}} 41 - </div> 42 - {{end}} 65 + {{if $.Inline}} 66 + <div> 67 + {{range .Items}} 68 + <div> 69 + <h1><a href="{{.Link}}">{{.Title}}</a></h1> 70 + <div>{{.Content}}</div> 71 + </div> 72 + <hr /> 73 + {{end}} 74 + </div> 75 + {{end}} 43 76 44 - <hr style="margin: 10px 0;" /> 45 - {{end}} 46 - </div> 77 + <hr style="margin: 10px 0;" /> 78 + {{end}} 79 + </div> 47 80 </body> 48 - </html> 81 + 82 + </html>
+7
email/templates/digest.txt
··· 1 + {{if .ShowUrgentBanner}} 2 + Your digest expires in {{.DaysUntilExpiry}} days. Click "keep this digest active" below to continue receiving updates or it will be deactivated. 3 + 4 + {{else if .ShowWarningBanner}} 5 + Your digest expires in {{.DaysUntilExpiry}} days. Click "keep this digest active" below to extend it. 6 + 7 + {{end}} 1 8 {{range .FeedGroups}} 2 9 {{.FeedName}} 3 10 {{.FeedURL}}
+15 -3
scheduler/scheduler.go
··· 354 354 inline = false 355 355 } 356 356 357 + // Calculate expiry info 358 + expiryDate := cfg.CreatedAt.AddDate(0, 0, 90) 359 + daysUntilExpiry := int(time.Until(expiryDate).Hours() / 24) 360 + showUrgentBanner := daysUntilExpiry <= 7 && daysUntilExpiry >= 0 361 + showWarningBanner := daysUntilExpiry > 7 && daysUntilExpiry <= 30 362 + 357 363 s.logger.Debug("sendDigestAndMarkSeen: rendering digest") 358 - htmlBody, textBody, err := email.RenderDigest(digestData, inline) 364 + htmlBody, textBody, err := email.RenderDigest(digestData, inline, daysUntilExpiry, showUrgentBanner, showWarningBanner) 359 365 if err != nil { 360 366 return fmt.Errorf("render digest: %w", err) 361 367 } ··· 404 410 } 405 411 s.logger.Debug("sendDigestAndMarkSeen: items marked seen") 406 412 407 - // Generate tracking token BEFORE recording (needed for email pixel URL) 413 + // Generate tracking token BEFORE recording (needed for keep-alive URL) 408 414 trackingToken, err := s.store.GenerateTrackingToken() 409 415 if err != nil { 410 416 s.logger.Warn("failed to generate tracking token", "err", err) ··· 420 426 } 421 427 s.logger.Debug("sendDigestAndMarkSeen: recorded email send") 422 428 429 + // Build keep-alive URL 430 + keepAliveURL := "" 431 + if trackingToken != "" { 432 + keepAliveURL = s.originURL + "/keep-alive/" + trackingToken 433 + } 434 + 423 435 // Send email - if this fails, transaction will rollback 424 436 s.logger.Debug("sendDigestAndMarkSeen: calling mailer.Send", "to", cfg.Email) 425 - if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody, unsubToken, dashboardURL, trackingToken); err != nil { 437 + if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody, unsubToken, dashboardURL, keepAliveURL); err != nil { 426 438 s.logger.Error("sendDigestAndMarkSeen: mailer.Send failed", "err", err) 427 439 return fmt.Errorf("send email: %w", err) 428 440 }
+8 -7
store/configs.go
··· 21 21 LastRun sql.NullTime 22 22 NextRun sql.NullTime 23 23 CreatedAt time.Time 24 + LastActiveAt sql.NullTime 24 25 } 25 26 26 27 func (db *DB) CreateConfig(ctx context.Context, userID int64, filename, email, cronExpr string, digest, inline bool, rawText string, nextRun time.Time) (*Config, error) { ··· 102 103 103 104 func (db *DB) GetConfig(ctx context.Context, userID int64, filename string) (*Config, error) { 104 105 var cfg Config 105 - err := db.stmts.getConfig.QueryRowContext(ctx, userID, filename).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 106 + err := db.stmts.getConfig.QueryRowContext(ctx, userID, filename).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt, &cfg.LastActiveAt) 106 107 if err != nil { 107 108 return nil, err 108 109 } ··· 112 113 func (db *DB) GetConfigByID(ctx context.Context, id int64) (*Config, error) { 113 114 var cfg Config 114 115 err := db.QueryRowContext(ctx, 115 - `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 116 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at, last_active_at 116 117 FROM configs WHERE id = ?`, 117 118 id, 118 - ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 119 + ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt, &cfg.LastActiveAt) 119 120 if err != nil { 120 121 return nil, err 121 122 } ··· 124 125 125 126 func (db *DB) ListConfigs(ctx context.Context, userID int64) ([]*Config, error) { 126 127 rows, err := db.QueryContext(ctx, 127 - `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 128 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at, last_active_at 128 129 FROM configs WHERE user_id = ? ORDER BY filename`, 129 130 userID, 130 131 ) ··· 136 137 var configs []*Config 137 138 for rows.Next() { 138 139 var cfg Config 139 - if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt); err != nil { 140 + if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt, &cfg.LastActiveAt); err != nil { 140 141 return nil, fmt.Errorf("scan config: %w", err) 141 142 } 142 143 configs = append(configs, &cfg) ··· 173 174 174 175 func (db *DB) GetDueConfigs(ctx context.Context, now time.Time) ([]*Config, error) { 175 176 rows, err := db.QueryContext(ctx, 176 - `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 177 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at, last_active_at 177 178 FROM configs WHERE next_run IS NOT NULL AND next_run <= ? ORDER BY next_run`, 178 179 now, 179 180 ) ··· 185 186 var configs []*Config 186 187 for rows.Next() { 187 188 var cfg Config 188 - if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt); err != nil { 189 + if err := rows.Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt, &cfg.LastActiveAt); err != nil { 189 190 return nil, fmt.Errorf("scan config: %w", err) 190 191 } 191 192 configs = append(configs, &cfg)
+2 -1
store/db.go
··· 70 70 last_run DATETIME, 71 71 next_run DATETIME, 72 72 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 73 + last_active_at DATETIME, 73 74 UNIQUE(user_id, filename) 74 75 ); 75 76 ··· 176 177 } 177 178 178 179 db.stmts.getConfig, err = db.Prepare( 179 - `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 180 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at, last_active_at 180 181 FROM configs WHERE user_id = ? AND filename = ?`) 181 182 if err != nil { 182 183 return fmt.Errorf("prepare getConfig: %w", err)
+37 -13
store/tracking.go
··· 87 87 return nil 88 88 } 89 89 90 - // GetInactiveConfigs returns config IDs that haven't had opens in the specified days 91 - func (db *DB) GetInactiveConfigs(daysWithoutOpen int, minSends int) ([]int64, error) { 90 + // GetInactiveConfigs returns config IDs that haven't had keep-alive activity in the specified days 91 + func (db *DB) GetInactiveConfigs(daysWithoutActivity int, minSends int) ([]int64, error) { 92 92 query := ` 93 - SELECT DISTINCT es.config_id 94 - FROM email_sends es 95 - WHERE es.config_id IN ( 93 + SELECT DISTINCT c.id 94 + FROM configs c 95 + INNER JOIN email_sends es ON es.config_id = c.id 96 + WHERE c.id IN ( 96 97 SELECT config_id 97 98 FROM email_sends 98 99 GROUP BY config_id 99 100 HAVING COUNT(*) >= ? 100 101 ) 101 - AND es.sent_at > datetime('now', '-' || ? || ' days') 102 - AND es.config_id NOT IN ( 103 - SELECT config_id 104 - FROM email_sends 105 - WHERE opened = TRUE 106 - AND sent_at > datetime('now', '-' || ? || ' days') 102 + AND ( 103 + c.last_active_at IS NULL 104 + OR c.last_active_at < datetime('now', '-' || ? || ' days') 107 105 ) 108 - GROUP BY es.config_id 106 + AND c.created_at < datetime('now', '-' || ? || ' days') 107 + GROUP BY c.id 109 108 ` 110 109 111 - rows, err := db.Query(query, minSends, daysWithoutOpen, daysWithoutOpen) 110 + rows, err := db.Query(query, minSends, daysWithoutActivity, daysWithoutActivity) 112 111 if err != nil { 113 112 return nil, fmt.Errorf("query inactive configs: %w", err) 114 113 } ··· 197 196 } 198 197 return base64.URLEncoding.EncodeToString(b), nil 199 198 } 199 + 200 + // UpdateLastActive updates the last_active_at timestamp for a config by tracking token 201 + func (db *DB) UpdateLastActive(trackingToken string) error { 202 + query := `UPDATE configs 203 + SET last_active_at = CURRENT_TIMESTAMP 204 + WHERE id = ( 205 + SELECT config_id FROM email_sends WHERE tracking_token = ? LIMIT 1 206 + )` 207 + result, err := db.Exec(query, trackingToken) 208 + if err != nil { 209 + return fmt.Errorf("update last active: %w", err) 210 + } 211 + 212 + rows, err := result.RowsAffected() 213 + if err != nil { 214 + return fmt.Errorf("rows affected: %w", err) 215 + } 216 + 217 + if rows == 0 { 218 + return fmt.Errorf("tracking token not found") 219 + } 220 + 221 + return nil 222 + } 223 +
+58 -48
web/handlers.go
··· 70 70 } 71 71 72 72 type configInfo struct { 73 - Filename string 74 - FeedCount int 75 - URL string 76 - FeedXMLURL string 77 - FeedJSONURL string 78 - IsActive bool 79 - TotalSends int 80 - Opens int 81 - OpenRate float64 82 - LastOpenDays int 73 + Filename string 74 + FeedCount int 75 + URL string 76 + FeedXMLURL string 77 + FeedJSONURL string 78 + IsActive bool 79 + TotalSends int 80 + LastActiveDays int 81 + DaysUntilExpiry int 83 82 } 84 83 85 84 func (s *Server) handleUser(w http.ResponseWriter, r *http.Request, fingerprint string) { ··· 134 133 feedBaseName := strings.TrimSuffix(cfg.Filename, ".txt") 135 134 136 135 // Get engagement stats (last 90 days) 137 - totalSends, opens, _, lastOpen, err := s.store.GetConfigEngagement(cfg.ID, 90) 136 + totalSends, _, _, _, err := s.store.GetConfigEngagement(cfg.ID, 90) 138 137 if err != nil { 139 138 s.logger.Warn("get engagement", "config_id", cfg.ID, "err", err) 140 139 // Continue without engagement data 141 140 } 142 141 143 - openRate := 0.0 144 - if totalSends > 0 { 145 - openRate = float64(opens) / float64(totalSends) * 100 142 + // Calculate last active days and expiry 143 + lastActiveDays := -1 144 + if cfg.LastActiveAt.Valid { 145 + lastActiveDays = int(time.Since(cfg.LastActiveAt.Time).Hours() / 24) 146 146 } 147 147 148 - lastOpenDays := -1 149 - if lastOpen != nil { 150 - lastOpenDays = int(time.Since(*lastOpen).Hours() / 24) 148 + // Calculate expiry from the most recent of created_at or last_active_at 149 + expiryBase := cfg.CreatedAt 150 + if cfg.LastActiveAt.Valid && cfg.LastActiveAt.Time.After(cfg.CreatedAt) { 151 + expiryBase = cfg.LastActiveAt.Time 151 152 } 153 + expiryDate := expiryBase.AddDate(0, 0, 90) 154 + daysUntilExpiry := int(time.Until(expiryDate).Hours() / 24) 152 155 153 156 configInfos = append(configInfos, configInfo{ 154 - Filename: cfg.Filename, 155 - FeedCount: len(feeds), 156 - URL: "/" + fingerprint + "/" + cfg.Filename, 157 - FeedXMLURL: "/" + fingerprint + "/" + feedBaseName + ".xml", 158 - FeedJSONURL: "/" + fingerprint + "/" + feedBaseName + ".json", 159 - IsActive: isActive, 160 - TotalSends: totalSends, 161 - Opens: opens, 162 - OpenRate: openRate, 163 - LastOpenDays: lastOpenDays, 157 + Filename: cfg.Filename, 158 + FeedCount: len(feeds), 159 + URL: "/" + fingerprint + "/" + cfg.Filename, 160 + FeedXMLURL: "/" + fingerprint + "/" + feedBaseName + ".xml", 161 + FeedJSONURL: "/" + fingerprint + "/" + feedBaseName + ".json", 162 + IsActive: isActive, 163 + TotalSends: totalSends, 164 + LastActiveDays: lastActiveDays, 165 + DaysUntilExpiry: daysUntilExpiry, 164 166 }) 165 167 166 168 if cfg.NextRun.Valid { ··· 682 684 return hostPort 683 685 } 684 686 685 - func (s *Server) handleTrackingPixel(w http.ResponseWriter, r *http.Request, token string) { 687 + func (s *Server) handleKeepAlive(w http.ResponseWriter, r *http.Request, token string) { 686 688 // Only allow GET requests 687 689 if r.Method != http.MethodGet { 688 690 http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) 689 691 return 690 692 } 691 693 692 - // Mark email as opened 693 - if err := s.store.MarkEmailOpened(token); err != nil { 694 - // Log but still return pixel (don't leak token validity) 695 - s.logger.Debug("tracking pixel error", "token", token, "err", err) 694 + // Update last_active_at for this config 695 + if err := s.store.UpdateLastActive(token); err != nil { 696 + s.logger.Debug("keep-alive error", "token", token, "err", err) 697 + http.Error(w, "Invalid or expired link", http.StatusNotFound) 698 + return 696 699 } 697 700 698 - // Return 1x1 transparent GIF 699 - w.Header().Set("Content-Type", "image/gif") 700 - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 701 - w.Header().Set("Pragma", "no-cache") 702 - w.Header().Set("Expires", "0") 703 - 704 - // 1x1 transparent GIF (base64 decoded: R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7) 705 - gifBytes := []byte{ 706 - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 707 - 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 708 - 0xFF, 0xFF, 0xFF, 0x21, 0xF9, 0x04, 0x01, 0x00, 709 - 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 710 - 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 711 - 0x01, 0x00, 0x3B, 712 - } 713 - w.Write(gifBytes) 701 + // Calculate new expiry date (90 days from now) 702 + expiresAt := time.Now().AddDate(0, 0, 90).Format("January 2, 2006") 703 + 704 + // Return success message 705 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 706 + w.WriteHeader(http.StatusOK) 707 + fmt.Fprintf(w, `<!DOCTYPE html> 708 + <html> 709 + <head> 710 + <meta charset="utf-8"> 711 + <meta name="viewport" content="width=device-width, initial-scale=1"> 712 + <title>Digest Active</title> 713 + <style> 714 + body { font-family: system-ui, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; } 715 + .success { color: #059669; font-size: 24px; margin-bottom: 20px; } 716 + .details { color: #6b7280; font-size: 16px; } 717 + </style> 718 + </head> 719 + <body> 720 + <div class="success">✓ Success!</div> 721 + <div class="details">Your digest will stay active until <strong>%s</strong>.</div> 722 + </body> 723 + </html>`, expiresAt) 714 724 } 715 725 716 726 func (s *Server) handle404(w http.ResponseWriter, r *http.Request) {
+2 -4
web/server.go
··· 150 150 return 151 151 } 152 152 153 - if len(parts) == 2 && parts[0] == "t" && strings.HasSuffix(parts[1], ".gif") { 154 - // Tracking pixel: /t/{token}.gif 155 - token := strings.TrimSuffix(parts[1], ".gif") 156 - s.handleTrackingPixel(w, r, token) 153 + if len(parts) == 2 && parts[0] == "keep-alive" { 154 + s.handleKeepAlive(w, r, parts[1]) 157 155 return 158 156 } 159 157
+11 -2
web/templates/user.html
··· 21 21 - <a href="{{.FeedJSONURL}}">JSON</a> 22 22 {{if gt .TotalSends 0}} 23 23 <br><span style="font-size: 0.9em; color: #666;"> 24 - 📧 {{.TotalSends}} sent, {{.Opens}} opened ({{printf "%.1f" .OpenRate}}%) 25 - {{if ge .LastOpenDays 0}}• last opened {{.LastOpenDays}}d ago{{end}} 24 + 📧 {{.TotalSends}} sent 25 + {{if ge .LastActiveDays 0}} 26 + • last active {{.LastActiveDays}}d ago 27 + {{else}} 28 + • never clicked keep-alive 29 + {{end}} 30 + {{if le .DaysUntilExpiry 7}} 31 + • <strong style="color: #c00;">expires in {{.DaysUntilExpiry}}d</strong> 32 + {{else if le .DaysUntilExpiry 30}} 33 + • expires in {{.DaysUntilExpiry}}d 34 + {{end}} 26 35 </span> 27 36 {{end}} 28 37 </li>