+1
-1
.gitignore
+1
-1
.gitignore
+43
config/validate.go
+43
config/validate.go
···
1
1
package config
2
2
3
3
import (
4
+
"context"
4
5
"errors"
6
+
"fmt"
7
+
"net/http"
5
8
"net/mail"
6
9
"net/url"
10
+
"time"
7
11
8
12
"github.com/adhocore/gronx"
13
+
"github.com/mmcdole/gofeed"
9
14
)
10
15
11
16
var (
···
46
51
47
52
return nil
48
53
}
54
+
55
+
// ValidateFeedURLs attempts to fetch and parse each feed URL with a short timeout
56
+
func ValidateFeedURLs(ctx context.Context, cfg *ParsedConfig) error {
57
+
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
58
+
defer cancel()
59
+
60
+
parser := gofeed.NewParser()
61
+
client := &http.Client{
62
+
Timeout: 5 * time.Second,
63
+
}
64
+
65
+
for _, feed := range cfg.Feeds {
66
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.URL, nil)
67
+
if err != nil {
68
+
return fmt.Errorf("invalid feed URL %s: %w", feed.URL, err)
69
+
}
70
+
71
+
req.Header.Set("User-Agent", "Herald/1.0 (RSS Aggregator)")
72
+
73
+
resp, err := client.Do(req)
74
+
if err != nil {
75
+
return fmt.Errorf("failed to fetch feed %s: %w", feed.URL, err)
76
+
}
77
+
78
+
if resp.StatusCode != http.StatusOK {
79
+
resp.Body.Close()
80
+
return fmt.Errorf("feed %s returned status %d", feed.URL, resp.StatusCode)
81
+
}
82
+
83
+
_, err = parser.Parse(resp.Body)
84
+
resp.Body.Close()
85
+
if err != nil {
86
+
return fmt.Errorf("failed to parse feed %s: %w", feed.URL, err)
87
+
}
88
+
}
89
+
90
+
return nil
91
+
}
+66
email/send.go
+66
email/send.go
···
30
30
}
31
31
}
32
32
33
+
// ValidateConfig tests SMTP connectivity and auth
34
+
func (m *Mailer) ValidateConfig() error {
35
+
addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port))
36
+
37
+
var auth smtp.Auth
38
+
if m.cfg.User != "" && m.cfg.Pass != "" {
39
+
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Pass, m.cfg.Host)
40
+
}
41
+
42
+
// Port 465 uses implicit TLS
43
+
if m.cfg.Port == 465 {
44
+
tlsConfig := &tls.Config{
45
+
ServerName: m.cfg.Host,
46
+
}
47
+
48
+
conn, err := tls.Dial("tcp", addr, tlsConfig)
49
+
if err != nil {
50
+
return fmt.Errorf("TLS dial: %w", err)
51
+
}
52
+
defer conn.Close()
53
+
54
+
client, err := smtp.NewClient(conn, m.cfg.Host)
55
+
if err != nil {
56
+
return fmt.Errorf("SMTP client: %w", err)
57
+
}
58
+
defer client.Close()
59
+
60
+
if auth != nil {
61
+
if err = client.Auth(auth); err != nil {
62
+
return fmt.Errorf("auth: %w", err)
63
+
}
64
+
}
65
+
66
+
return client.Quit()
67
+
}
68
+
69
+
// Port 587 uses STARTTLS
70
+
conn, err := net.Dial("tcp", addr)
71
+
if err != nil {
72
+
return fmt.Errorf("dial: %w", err)
73
+
}
74
+
defer conn.Close()
75
+
76
+
client, err := smtp.NewClient(conn, m.cfg.Host)
77
+
if err != nil {
78
+
return fmt.Errorf("SMTP client: %w", err)
79
+
}
80
+
defer client.Close()
81
+
82
+
// Start TLS before auth
83
+
tlsConfig := &tls.Config{
84
+
ServerName: m.cfg.Host,
85
+
}
86
+
if err = client.StartTLS(tlsConfig); err != nil {
87
+
return fmt.Errorf("STARTTLS: %w", err)
88
+
}
89
+
90
+
if auth != nil {
91
+
if err = client.Auth(auth); err != nil {
92
+
return fmt.Errorf("auth: %w", err)
93
+
}
94
+
}
95
+
96
+
return client.Quit()
97
+
}
98
+
33
99
func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken, dashboardURL string) error {
34
100
addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port))
35
101
+5
main.go
+5
main.go
···
169
169
From: cfg.SMTP.From,
170
170
}, cfg.Origin)
171
171
172
+
// Validate SMTP configuration
173
+
if err := mailer.ValidateConfig(); err != nil {
174
+
return fmt.Errorf("SMTP validation failed: %w", err)
175
+
}
176
+
172
177
sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second, cfg.Origin)
173
178
174
179
sshServer := ssh.NewServer(ssh.Config{
+7
-2
ssh/scp.go
+7
-2
ssh/scp.go
···
137
137
return 0, fmt.Errorf("invalid config: %w", err)
138
138
}
139
139
140
+
ctx := s.Context()
141
+
142
+
// Validate feed URLs by attempting to fetch them
143
+
if err := config.ValidateFeedURLs(ctx, parsed); err != nil {
144
+
return 0, fmt.Errorf("feed validation failed: %w", err)
145
+
}
146
+
140
147
nextRun, err := calculateNextRun(parsed.CronExpr)
141
148
if err != nil {
142
149
return 0, fmt.Errorf("failed to calculate next run: %w", err)
143
150
}
144
-
145
-
ctx := s.Context()
146
151
147
152
// Use transaction for config update
148
153
tx, err := h.store.BeginTx(ctx)