rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
at main 222 lines 6.4 kB view raw
1package store 2 3import ( 4 "crypto/rand" 5 "database/sql" 6 "encoding/base64" 7 "fmt" 8 "time" 9) 10 11type EmailSend struct { 12 ID int64 13 ConfigID int64 14 Recipient string 15 Subject string 16 TrackingToken string 17 SentAt time.Time 18 Bounced bool 19 BounceReason sql.NullString 20 Opened bool 21 OpenedAt sql.NullTime 22} 23 24// RecordEmailSend records an email send with optional tracking token 25func (db *DB) RecordEmailSend(configID int64, recipient, subject string, includeTracking bool) (string, error) { 26 var trackingToken string 27 if includeTracking { 28 token, err := generateTrackingToken() 29 if err != nil { 30 return "", fmt.Errorf("generate tracking token: %w", err) 31 } 32 trackingToken = token 33 } 34 35 query := `INSERT INTO email_sends (config_id, recipient, subject, tracking_token) 36 VALUES (?, ?, ?, ?)` 37 _, err := db.Exec(query, configID, recipient, subject, sql.NullString{String: trackingToken, Valid: trackingToken != ""}) 38 if err != nil { 39 return "", fmt.Errorf("insert email send: %w", err) 40 } 41 42 return trackingToken, nil 43} 44 45// RecordEmailSendTx records an email send within an existing transaction 46func (db *DB) RecordEmailSendTx(tx *sql.Tx, configID int64, recipient, subject, trackingToken string) error { 47 query := `INSERT INTO email_sends (config_id, recipient, subject, tracking_token) 48 VALUES (?, ?, ?, ?)` 49 _, err := tx.Exec(query, configID, recipient, subject, sql.NullString{String: trackingToken, Valid: trackingToken != ""}) 50 if err != nil { 51 return fmt.Errorf("insert email send: %w", err) 52 } 53 return nil 54} 55 56// MarkEmailBounced marks an email as bounced 57func (db *DB) MarkEmailBounced(configID int64, recipient, reason string) error { 58 query := `UPDATE email_sends 59 SET bounced = TRUE, bounce_reason = ? 60 WHERE config_id = ? AND recipient = ? 61 AND sent_at > datetime('now', '-7 days') 62 ORDER BY sent_at DESC 63 LIMIT 1` 64 _, err := db.Exec(query, reason, configID, recipient) 65 return err 66} 67 68// MarkEmailOpened marks an email as opened via tracking token 69func (db *DB) MarkEmailOpened(trackingToken string) error { 70 query := `UPDATE email_sends 71 SET opened = TRUE, opened_at = CURRENT_TIMESTAMP 72 WHERE tracking_token = ? AND opened = FALSE` 73 result, err := db.Exec(query, trackingToken) 74 if err != nil { 75 return fmt.Errorf("update email opened: %w", err) 76 } 77 78 rows, err := result.RowsAffected() 79 if err != nil { 80 return fmt.Errorf("rows affected: %w", err) 81 } 82 83 if rows == 0 { 84 return fmt.Errorf("tracking token not found or already opened") 85 } 86 87 return nil 88} 89 90// GetInactiveConfigs returns config IDs that haven't had keep-alive activity in the specified days 91func (db *DB) GetInactiveConfigs(daysWithoutActivity int, minSends int) ([]int64, error) { 92 query := ` 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 ( 97 SELECT config_id 98 FROM email_sends 99 GROUP BY config_id 100 HAVING COUNT(*) >= ? 101 ) 102 AND ( 103 c.last_active_at IS NULL 104 OR c.last_active_at < datetime('now', '-' || ? || ' days') 105 ) 106 AND c.created_at < datetime('now', '-' || ? || ' days') 107 GROUP BY c.id 108 ` 109 110 rows, err := db.Query(query, minSends, daysWithoutActivity, daysWithoutActivity) 111 if err != nil { 112 return nil, fmt.Errorf("query inactive configs: %w", err) 113 } 114 defer func() { _ = rows.Close() }() 115 116 var configIDs []int64 117 for rows.Next() { 118 var id int64 119 if err := rows.Scan(&id); err != nil { 120 return nil, fmt.Errorf("scan config id: %w", err) 121 } 122 configIDs = append(configIDs, id) 123 } 124 125 return configIDs, rows.Err() 126} 127 128// GetConfigEngagement returns engagement stats for a config 129func (db *DB) GetConfigEngagement(configID int64, days int) (totalSends, opens, bounces int, lastOpen *time.Time, err error) { 130 // First get counts 131 countQuery := ` 132 SELECT 133 COUNT(*) as total_sends, 134 COALESCE(SUM(CASE WHEN opened = TRUE THEN 1 ELSE 0 END), 0) as opens, 135 COALESCE(SUM(CASE WHEN bounced = TRUE THEN 1 ELSE 0 END), 0) as bounces 136 FROM email_sends 137 WHERE config_id = ? 138 AND sent_at > datetime('now', '-' || ? || ' days') 139 ` 140 141 err = db.QueryRow(countQuery, configID, days).Scan(&totalSends, &opens, &bounces) 142 if err != nil { 143 return 0, 0, 0, nil, fmt.Errorf("query engagement counts: %w", err) 144 } 145 146 // Get most recent open 147 openQuery := ` 148 SELECT opened_at 149 FROM email_sends 150 WHERE config_id = ? 151 AND opened = TRUE 152 AND sent_at > datetime('now', '-' || ? || ' days') 153 ORDER BY opened_at DESC 154 LIMIT 1 155 ` 156 157 var lastOpenStr sql.NullString 158 err = db.QueryRow(openQuery, configID, days).Scan(&lastOpenStr) 159 if err != nil && err != sql.ErrNoRows { 160 return 0, 0, 0, nil, fmt.Errorf("query last open: %w", err) 161 } 162 163 if lastOpenStr.Valid && lastOpenStr.String != "" { 164 t, err := time.Parse("2006-01-02 15:04:05", lastOpenStr.String) 165 if err == nil { 166 lastOpen = &t 167 } 168 } 169 170 return totalSends, opens, bounces, lastOpen, nil 171} 172 173// CleanupOldSends removes email send records older than specified days 174func (db *DB) CleanupOldSends(daysToKeep int) (int64, error) { 175 query := `DELETE FROM email_sends WHERE sent_at < datetime('now', '-' || ? || ' days')` 176 result, err := db.Exec(query, daysToKeep) 177 if err != nil { 178 return 0, fmt.Errorf("cleanup old sends: %w", err) 179 } 180 return result.RowsAffected() 181} 182 183// GenerateTrackingToken generates a secure random tracking token 184func (db *DB) GenerateTrackingToken() (string, error) { 185 b := make([]byte, 24) 186 if _, err := rand.Read(b); err != nil { 187 return "", err 188 } 189 return base64.URLEncoding.EncodeToString(b), nil 190} 191 192func generateTrackingToken() (string, error) { 193 b := make([]byte, 24) 194 if _, err := rand.Read(b); err != nil { 195 return "", err 196 } 197 return base64.URLEncoding.EncodeToString(b), nil 198} 199 200// UpdateLastActive updates the last_active_at timestamp for a config by tracking token 201func (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}