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

feat: update digest template

dunkirk.sh 09db9b53 42634365

verified
+164 -21
+36 -16
email/templates/digest.html
··· 6 6 <style> 7 7 body { 8 8 font-family: monospace; 9 - background-color: #fff; 10 - color: #000; 11 9 margin: 0; 12 10 padding: 20px; 13 11 line-height: 1.6; 14 12 max-width: 600px; 15 13 } 16 14 a { 17 - color: #000; 18 15 word-wrap: break-word; 19 16 } 20 - h2 { 17 + h1 { 21 18 font-size: 16px; 22 19 font-weight: bold; 23 20 margin: 20px 0 5px 0; 24 21 } 25 - h3 { 22 + h2 { 26 23 font-size: 14px; 27 24 font-weight: bold; 28 25 margin: 15px 0 10px 0; 29 26 } 27 + img { 28 + max-width: 100%; 29 + height: auto; 30 + } 30 31 .feed-url { 31 32 font-size: 14px; 32 33 margin-bottom: 10px; 33 34 } 34 - .item { 35 - margin-bottom: 20px; 35 + .feeds { 36 + max-width: 100%; 36 37 } 37 - .item-title { 38 - font-weight: bold; 38 + .summary ul { 39 + margin: 5px 0; 40 + padding-left: 20px; 41 + } 42 + .summary li { 39 43 margin-bottom: 5px; 40 44 } 45 + .item { 46 + margin-bottom: 20px; 47 + } 41 48 .item-content { 42 49 margin-top: 10px; 43 - white-space: pre-wrap; 44 50 } 45 51 </style> 46 52 </head> 47 53 <body> 54 + <div class="feeds"> 48 55 {{range .FeedGroups}} 49 - <h2>{{.FeedName}}</h2> 50 - <div class="feed-url"><a href="{{.FeedURL}}">{{.FeedURL}}</a></div> 56 + <div style="margin-bottom: 10px;"> 57 + <h1><a href="{{.FeedURL}}">{{.FeedName}}</a></h1> 58 + </div> 51 59 52 - <h3>Summary</h3> 60 + <div class="summary"> 61 + <h2>Entries</h2> 62 + {{range .Items}} 63 + <ul> 64 + <li><a href="{{.Link}}">{{.Title}}</a></li> 65 + </ul> 66 + {{end}} 67 + <hr /> 68 + </div> 53 69 70 + {{if $.Inline}} 71 + <div> 54 72 {{range .Items}} 55 73 <div class="item"> 56 - <div class="item-title">{{.Title}}</div> 57 - <div><a href="{{.Link}}">{{.Link}}</a></div> 58 - {{if and $.Inline .Content}} 74 + <h1><a href="{{.Link}}">{{.Title}}</a></h1> 59 75 <div class="item-content">{{.Content}}</div> 76 + </div> 77 + <hr /> 60 78 {{end}} 61 79 </div> 62 80 {{end}} 63 81 82 + <hr style="margin: 10px 0;" /> 64 83 {{end}} 84 + </div> 65 85 </body> 66 86 </html>
+121 -3
scheduler/scheduler.go
··· 60 60 } 61 61 } 62 62 63 - func (s *Scheduler) RunNow(ctx context.Context, configID int64) error { 63 + func (s *Scheduler) RunNow(ctx context.Context, configID int64) (int, error) { 64 64 cfg, err := s.store.GetConfigByID(ctx, configID) 65 65 if err != nil { 66 - return fmt.Errorf("get config: %w", err) 66 + return 0, fmt.Errorf("get config: %w", err) 67 67 } 68 - return s.processConfig(ctx, cfg) 68 + 69 + feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 70 + if err != nil { 71 + return 0, fmt.Errorf("get feeds: %w", err) 72 + } 73 + 74 + if len(feeds) == 0 { 75 + return 0, fmt.Errorf("no feeds configured") 76 + } 77 + 78 + results := FetchFeeds(ctx, feeds) 79 + 80 + var feedGroups []email.FeedGroup 81 + totalNew := 0 82 + threeMonthsAgo := time.Now().AddDate(0, -3, 0) 83 + feedErrors := 0 84 + 85 + for _, result := range results { 86 + if result.Error != nil { 87 + s.logger.Warn("feed fetch error", "feed_id", result.FeedID, "url", result.FeedURL, "err", result.Error) 88 + feedErrors++ 89 + continue 90 + } 91 + 92 + var newItems []email.FeedItem 93 + for _, item := range result.Items { 94 + if !item.Published.IsZero() && item.Published.Before(threeMonthsAgo) { 95 + continue 96 + } 97 + 98 + seen, err := s.store.IsItemSeen(ctx, result.FeedID, item.GUID) 99 + if err != nil { 100 + s.logger.Warn("failed to check if item seen", "err", err) 101 + continue 102 + } 103 + 104 + if !seen { 105 + newItems = append(newItems, email.FeedItem{ 106 + Title: item.Title, 107 + Link: item.Link, 108 + Content: item.Content, 109 + Published: item.Published, 110 + }) 111 + } 112 + } 113 + 114 + if len(newItems) > 0 { 115 + feedName := result.FeedName 116 + if feedName == "" { 117 + feedName = result.FeedURL 118 + } 119 + feedGroups = append(feedGroups, email.FeedGroup{ 120 + FeedName: feedName, 121 + FeedURL: result.FeedURL, 122 + Items: newItems, 123 + }) 124 + totalNew += len(newItems) 125 + } 126 + 127 + if result.ETag != "" || result.LastModified != "" { 128 + if err := s.store.UpdateFeedFetched(ctx, result.FeedID, result.ETag, result.LastModified); err != nil { 129 + s.logger.Warn("failed to update feed fetched", "err", err) 130 + } 131 + } 132 + } 133 + 134 + if feedErrors == len(results) { 135 + return 0, fmt.Errorf("all feeds failed to fetch") 136 + } 137 + 138 + if totalNew > 0 { 139 + digestData := &email.DigestData{ 140 + ConfigName: cfg.Filename, 141 + TotalItems: totalNew, 142 + FeedGroups: feedGroups, 143 + } 144 + 145 + inline := cfg.InlineContent 146 + if totalNew > 5 { 147 + inline = false 148 + } 149 + 150 + htmlBody, textBody, err := email.RenderDigest(digestData, inline) 151 + if err != nil { 152 + return 0, fmt.Errorf("render digest: %w", err) 153 + } 154 + 155 + subject := "feed digest" 156 + if err := s.mailer.Send(cfg.Email, subject, htmlBody, textBody); err != nil { 157 + return 0, fmt.Errorf("send email: %w", err) 158 + } 159 + 160 + s.logger.Info("email sent", "to", cfg.Email, "items", totalNew) 161 + 162 + for _, result := range results { 163 + if result.Error != nil { 164 + continue 165 + } 166 + for _, item := range result.Items { 167 + if err := s.store.MarkItemSeen(ctx, result.FeedID, item.GUID, item.Title, item.Link); err != nil { 168 + s.logger.Warn("failed to mark item seen", "err", err) 169 + } 170 + } 171 + } 172 + } 173 + 174 + now := time.Now() 175 + nextRun, err := gronx.NextTick(cfg.CronExpr, false) 176 + if err != nil { 177 + return totalNew, fmt.Errorf("calculate next run: %w", err) 178 + } 179 + 180 + if err := s.store.UpdateLastRun(ctx, cfg.ID, now, nextRun); err != nil { 181 + return totalNew, fmt.Errorf("update last run: %w", err) 182 + } 183 + 184 + _ = s.store.AddLog(ctx, cfg.ID, "info", fmt.Sprintf("Processed: %d new items, next run: %s", totalNew, nextRun.Format(time.RFC3339))) 185 + 186 + return totalNew, nil 69 187 } 70 188 71 189 func (s *Scheduler) processConfig(ctx context.Context, cfg *store.Config) error {
+7 -2
ssh/commands.go
··· 129 129 130 130 fmt.Fprintln(sess, "Running "+filename+"...") 131 131 132 - if err := sched.RunNow(ctx, cfg.ID); err != nil { 132 + newItems, err := sched.RunNow(ctx, cfg.ID) 133 + if err != nil { 133 134 fmt.Fprintln(sess, errorStyle.Render("Error: "+err.Error())) 134 135 return 135 136 } 136 137 137 - fmt.Fprintln(sess, successStyle.Render("Done! Check your email.")) 138 + if newItems == 0 { 139 + fmt.Fprintln(sess, dimStyle.Render("No new items found.")) 140 + } else { 141 + fmt.Fprintln(sess, successStyle.Render(fmt.Sprintf("Sent %d new item(s) to %s", newItems, cfg.Email))) 142 + } 138 143 } 139 144 140 145 func handleLogs(ctx context.Context, sess ssh.Session, user *store.User, st *store.DB) {