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