Skip to content

Commit bd2fdd3

Browse files
committed
syz-cluster: implement SMTP sender
Format raw emails and send them over SMTP. Take credentials from the secret storage.
1 parent f958dc9 commit bd2fdd3

File tree

4 files changed

+188
-10
lines changed

4 files changed

+188
-10
lines changed

syz-cluster/email-reporter/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ WORKDIR /build
66
COPY go.mod ./
77
COPY go.sum ./
88
RUN go mod download
9+
COPY pkg/auth/ pkg/auth/
910
COPY pkg/gcs/ pkg/gcs/
11+
COPY pkg/gce/ pkg/gce/
12+
COPY pkg/email/ pkg/email/
13+
COPY dashboard/dashapi/ dashboard/dashapi/
1014

1115
# Build the tool.
1216
COPY syz-cluster/email-reporter/ syz-cluster/email-reporter/

syz-cluster/email-reporter/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ func main() {
3030
if cfg.EmailReporting == nil {
3131
app.Fatalf("reporting is not configured: %v", err)
3232
}
33-
sender := &smtpSender{}
33+
sender, err := newSender(ctx, cfg.EmailReporting)
34+
if err != nil {
35+
app.Fatalf("failed to create an SMTP sender: %s", err)
36+
}
3437
handler := &Handler{
3538
apiClient: app.DefaultReporterClient(),
3639
emailConfig: cfg.EmailReporting,

syz-cluster/email-reporter/sender.go

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,144 @@
33

44
package main
55

6-
import "context"
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"net/smtp"
11+
"strconv"
12+
"strings"
713

8-
// TODO: how can we test it?
9-
// Using some STMP server library is probably an overkill?
14+
"github.com/google/syzkaller/pkg/gce"
15+
"github.com/google/syzkaller/syz-cluster/pkg/app"
16+
"github.com/google/uuid"
17+
)
1018

1119
type smtpSender struct {
20+
cfg *app.EmailConfig
21+
projectName string // needed for querying credentials
22+
}
23+
24+
func newSender(ctx context.Context, cfg *app.EmailConfig) (*smtpSender, error) {
25+
project, err := gce.ProjectName(ctx)
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to query project name: %w", err)
28+
}
29+
return &smtpSender{
30+
cfg: cfg,
31+
projectName: project,
32+
}, nil
1233
}
1334

1435
// Send constructs a raw email from EmailToSend and sends it over SMTP.
1536
func (sender *smtpSender) Send(ctx context.Context, item *EmailToSend) (string, error) {
16-
// TODO:
17-
// 1) Fill in email headers, including the Message ID.
18-
// https://pkg.go.dev/github.com/emersion/go-message/mail#Header.GenerateMessageIDWithHostname
19-
// 2) Send over STMP:
20-
// https://pkg.go.dev/net/smtp
21-
return "", nil
37+
creds, err := sender.queryCredentials(ctx)
38+
if err != nil {
39+
return "", fmt.Errorf("failed to query credentials: %w", err)
40+
}
41+
msgID := fmt.Sprintf("<%s@%s>", uuid.NewString(), creds.host)
42+
msg := rawEmail(sender.cfg, item, msgID)
43+
auth := smtp.PlainAuth("", creds.host, creds.password, creds.host)
44+
smtpAddr := fmt.Sprintf("%s:%d", creds.host, creds.port)
45+
return msgID, smtp.SendMail(smtpAddr, auth, sender.cfg.Sender, item.recipients(), msg)
46+
}
47+
48+
func (item *EmailToSend) recipients() []string {
49+
var ret []string
50+
ret = append(ret, item.To...)
51+
ret = append(ret, item.Cc...)
52+
return unique(ret)
53+
}
54+
55+
func unique(list []string) []string {
56+
var ret []string
57+
seen := map[string]struct{}{}
58+
for _, str := range list {
59+
if _, ok := seen[str]; ok {
60+
continue
61+
}
62+
seen[str] = struct{}{}
63+
ret = append(ret, str)
64+
}
65+
return ret
66+
}
67+
68+
func rawEmail(cfg *app.EmailConfig, item *EmailToSend, id string) []byte {
69+
var msg bytes.Buffer
70+
71+
fmt.Fprintf(&msg, "From: %s <%s>\r\n", cfg.Name, cfg.Sender)
72+
fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(item.To, ", "))
73+
if len(item.Cc) > 0 {
74+
fmt.Fprintf(&msg, "Cc: %s\r\n", strings.Join(item.Cc, ", "))
75+
}
76+
fmt.Fprintf(&msg, "Subject: %s\r\n", item.Subject)
77+
if item.InReplyTo != "" {
78+
inReplyTo := item.InReplyTo
79+
if inReplyTo[0] != '<' {
80+
inReplyTo = "<" + inReplyTo + ">"
81+
}
82+
fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo)
83+
}
84+
if id != "" {
85+
if id[0] != '<' {
86+
id = "<" + id + ">"
87+
}
88+
fmt.Fprintf(&msg, "Message-ID: %s\r\n", id)
89+
}
90+
msg.WriteString("MIME-Version: 1.0\r\n")
91+
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
92+
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
93+
msg.WriteString("\r\n")
94+
msg.Write(item.Body)
95+
return msg.Bytes()
96+
}
97+
98+
const (
99+
SecretSMTPHost string = "smtp_host"
100+
SecretSMTPPort string = "smtp_port"
101+
SecretSMTPUser string = "smtp_user"
102+
SecretSMTPPassword string = "smtp_password"
103+
)
104+
105+
type smtpCredentials struct {
106+
host string
107+
port int
108+
user string
109+
password string
110+
}
111+
112+
func (sender *smtpSender) queryCredentials(ctx context.Context) (smtpCredentials, error) {
113+
values := map[string]string{}
114+
for _, key := range []string{
115+
SecretSMTPHost, SecretSMTPPort, SecretSMTPUser, SecretSMTPPassword,
116+
} {
117+
var err error
118+
values[key], err = sender.querySecret(ctx, key)
119+
if err != nil {
120+
return smtpCredentials{}, err
121+
}
122+
}
123+
port, err := strconv.Atoi(values[SecretSMTPPort])
124+
if err != nil {
125+
return smtpCredentials{}, fmt.Errorf("failed to parse SMTP port: not a valid integer")
126+
}
127+
return smtpCredentials{
128+
host: values[SecretSMTPHost],
129+
port: port,
130+
user: values[SecretSMTPUser],
131+
password: values[SecretSMTPPassword],
132+
}, nil
133+
}
134+
135+
func (sender *smtpSender) querySecret(ctx context.Context, key string) (string, error) {
136+
const retries = 3
137+
var err error
138+
for i := 0; i < retries; i++ {
139+
var val []byte
140+
val, err := gce.LatestGcpSecret(ctx, sender.projectName, key)
141+
if err == nil {
142+
return string(val), nil
143+
}
144+
}
145+
return "", fmt.Errorf("failed to query %v: %w", key, err)
22146
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 syzkaller project authors. All rights reserved.
2+
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
3+
4+
package main
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestRawEmail(t *testing.T) {
14+
tests := []struct {
15+
item *EmailToSend
16+
id string
17+
result string
18+
}{
19+
{
20+
item: &EmailToSend{
21+
To: []string{"1@to.com", "2@to.com"},
22+
Cc: []string{"1@cc.com", "2@cc.com"},
23+
InReplyTo: "<reply-to@domain>",
24+
Subject: "subject",
25+
Body: []byte("Email body"),
26+
},
27+
id: "<id@domain>",
28+
result: "From: name <a@b.com>\r\n" +
29+
"To: 1@to.com, 2@to.com\r\n" +
30+
"Cc: 1@cc.com, 2@cc.com\r\n" +
31+
"Subject: subject\r\n" +
32+
"In-Reply-To: <reply-to@domain>\r\n" +
33+
"Message-ID: <id@domain>\r\n" +
34+
"MIME-Version: 1.0\r\n" +
35+
"Content-Type: text/plain; charset=UTF-8\r\n" +
36+
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
37+
"Email body",
38+
},
39+
}
40+
41+
for i, test := range tests {
42+
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
43+
ret := rawEmail(testEmailConfig, test.item, test.id)
44+
assert.Equal(t, test.result, string(ret))
45+
})
46+
}
47+
}

0 commit comments

Comments
 (0)