aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAleksandr Nogikh <nogikh@google.com>2025-05-07 17:16:25 +0200
committerTaras Madan <tarasmadan@google.com>2025-05-09 08:56:10 +0000
commit3af71fd3fd904701cb3b0d9047f2abe22458f7cf (patch)
treef98b7ac6428f0139aa879da4311312ccf53205a5
parent8ea579ebca8bcbeb1325837666a3093e86d45fa4 (diff)
syz-cluster: implement SMTP sender
Format raw emails and send them over SMTP. Take credentials from the secret storage.
-rw-r--r--syz-cluster/email-reporter/Dockerfile4
-rw-r--r--syz-cluster/email-reporter/main.go5
-rw-r--r--syz-cluster/email-reporter/sender.go142
-rw-r--r--syz-cluster/email-reporter/sender_test.go47
4 files changed, 188 insertions, 10 deletions
diff --git a/syz-cluster/email-reporter/Dockerfile b/syz-cluster/email-reporter/Dockerfile
index 779544fe3..49ab103aa 100644
--- a/syz-cluster/email-reporter/Dockerfile
+++ b/syz-cluster/email-reporter/Dockerfile
@@ -6,7 +6,11 @@ WORKDIR /build
COPY go.mod ./
COPY go.sum ./
RUN go mod download
+COPY pkg/auth/ pkg/auth/
COPY pkg/gcs/ pkg/gcs/
+COPY pkg/gce/ pkg/gce/
+COPY pkg/email/ pkg/email/
+COPY dashboard/dashapi/ dashboard/dashapi/
# Build the tool.
COPY syz-cluster/email-reporter/ syz-cluster/email-reporter/
diff --git a/syz-cluster/email-reporter/main.go b/syz-cluster/email-reporter/main.go
index 52bc3f651..1c030679d 100644
--- a/syz-cluster/email-reporter/main.go
+++ b/syz-cluster/email-reporter/main.go
@@ -30,7 +30,10 @@ func main() {
if cfg.EmailReporting == nil {
app.Fatalf("reporting is not configured: %v", err)
}
- sender := &smtpSender{}
+ sender, err := newSender(ctx, cfg.EmailReporting)
+ if err != nil {
+ app.Fatalf("failed to create an SMTP sender: %s", err)
+ }
handler := &Handler{
apiClient: app.DefaultReporterClient(),
emailConfig: cfg.EmailReporting,
diff --git a/syz-cluster/email-reporter/sender.go b/syz-cluster/email-reporter/sender.go
index 31a1b3358..e19066ed2 100644
--- a/syz-cluster/email-reporter/sender.go
+++ b/syz-cluster/email-reporter/sender.go
@@ -3,20 +3,144 @@
package main
-import "context"
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/smtp"
+ "strconv"
+ "strings"
-// TODO: how can we test it?
-// Using some STMP server library is probably an overkill?
+ "github.com/google/syzkaller/pkg/gce"
+ "github.com/google/syzkaller/syz-cluster/pkg/app"
+ "github.com/google/uuid"
+)
type smtpSender struct {
+ cfg *app.EmailConfig
+ projectName string // needed for querying credentials
+}
+
+func newSender(ctx context.Context, cfg *app.EmailConfig) (*smtpSender, error) {
+ project, err := gce.ProjectName(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query project name: %w", err)
+ }
+ return &smtpSender{
+ cfg: cfg,
+ projectName: project,
+ }, nil
}
// Send constructs a raw email from EmailToSend and sends it over SMTP.
func (sender *smtpSender) Send(ctx context.Context, item *EmailToSend) (string, error) {
- // TODO:
- // 1) Fill in email headers, including the Message ID.
- // https://pkg.go.dev/github.com/emersion/go-message/mail#Header.GenerateMessageIDWithHostname
- // 2) Send over STMP:
- // https://pkg.go.dev/net/smtp
- return "", nil
+ creds, err := sender.queryCredentials(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to query credentials: %w", err)
+ }
+ msgID := fmt.Sprintf("<%s@%s>", uuid.NewString(), creds.host)
+ msg := rawEmail(sender.cfg, item, msgID)
+ auth := smtp.PlainAuth("", creds.host, creds.password, creds.host)
+ smtpAddr := fmt.Sprintf("%s:%d", creds.host, creds.port)
+ return msgID, smtp.SendMail(smtpAddr, auth, sender.cfg.Sender, item.recipients(), msg)
+}
+
+func (item *EmailToSend) recipients() []string {
+ var ret []string
+ ret = append(ret, item.To...)
+ ret = append(ret, item.Cc...)
+ return unique(ret)
+}
+
+func unique(list []string) []string {
+ var ret []string
+ seen := map[string]struct{}{}
+ for _, str := range list {
+ if _, ok := seen[str]; ok {
+ continue
+ }
+ seen[str] = struct{}{}
+ ret = append(ret, str)
+ }
+ return ret
+}
+
+func rawEmail(cfg *app.EmailConfig, item *EmailToSend, id string) []byte {
+ var msg bytes.Buffer
+
+ fmt.Fprintf(&msg, "From: %s <%s>\r\n", cfg.Name, cfg.Sender)
+ fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(item.To, ", "))
+ if len(item.Cc) > 0 {
+ fmt.Fprintf(&msg, "Cc: %s\r\n", strings.Join(item.Cc, ", "))
+ }
+ fmt.Fprintf(&msg, "Subject: %s\r\n", item.Subject)
+ if item.InReplyTo != "" {
+ inReplyTo := item.InReplyTo
+ if inReplyTo[0] != '<' {
+ inReplyTo = "<" + inReplyTo + ">"
+ }
+ fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo)
+ }
+ if id != "" {
+ if id[0] != '<' {
+ id = "<" + id + ">"
+ }
+ fmt.Fprintf(&msg, "Message-ID: %s\r\n", id)
+ }
+ msg.WriteString("MIME-Version: 1.0\r\n")
+ msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
+ msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
+ msg.WriteString("\r\n")
+ msg.Write(item.Body)
+ return msg.Bytes()
+}
+
+const (
+ SecretSMTPHost string = "smtp_host"
+ SecretSMTPPort string = "smtp_port"
+ SecretSMTPUser string = "smtp_user"
+ SecretSMTPPassword string = "smtp_password"
+)
+
+type smtpCredentials struct {
+ host string
+ port int
+ user string
+ password string
+}
+
+func (sender *smtpSender) queryCredentials(ctx context.Context) (smtpCredentials, error) {
+ values := map[string]string{}
+ for _, key := range []string{
+ SecretSMTPHost, SecretSMTPPort, SecretSMTPUser, SecretSMTPPassword,
+ } {
+ var err error
+ values[key], err = sender.querySecret(ctx, key)
+ if err != nil {
+ return smtpCredentials{}, err
+ }
+ }
+ port, err := strconv.Atoi(values[SecretSMTPPort])
+ if err != nil {
+ return smtpCredentials{}, fmt.Errorf("failed to parse SMTP port: not a valid integer")
+ }
+ return smtpCredentials{
+ host: values[SecretSMTPHost],
+ port: port,
+ user: values[SecretSMTPUser],
+ password: values[SecretSMTPPassword],
+ }, nil
+}
+
+func (sender *smtpSender) querySecret(ctx context.Context, key string) (string, error) {
+ const retries = 3
+ var err error
+ for i := 0; i < retries; i++ {
+ var val []byte
+ val, err := gce.LatestGcpSecret(ctx, sender.projectName, key)
+ if err == nil {
+ return string(val), nil
+ }
+ }
+ return "", fmt.Errorf("failed to query %v: %w", key, err)
}
diff --git a/syz-cluster/email-reporter/sender_test.go b/syz-cluster/email-reporter/sender_test.go
new file mode 100644
index 000000000..184753a18
--- /dev/null
+++ b/syz-cluster/email-reporter/sender_test.go
@@ -0,0 +1,47 @@
+// Copyright 2025 syzkaller project authors. All rights reserved.
+// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+package main
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRawEmail(t *testing.T) {
+ tests := []struct {
+ item *EmailToSend
+ id string
+ result string
+ }{
+ {
+ item: &EmailToSend{
+ To: []string{"1@to.com", "2@to.com"},
+ Cc: []string{"1@cc.com", "2@cc.com"},
+ InReplyTo: "<reply-to@domain>",
+ Subject: "subject",
+ Body: []byte("Email body"),
+ },
+ id: "<id@domain>",
+ result: "From: name <a@b.com>\r\n" +
+ "To: 1@to.com, 2@to.com\r\n" +
+ "Cc: 1@cc.com, 2@cc.com\r\n" +
+ "Subject: subject\r\n" +
+ "In-Reply-To: <reply-to@domain>\r\n" +
+ "Message-ID: <id@domain>\r\n" +
+ "MIME-Version: 1.0\r\n" +
+ "Content-Type: text/plain; charset=UTF-8\r\n" +
+ "Content-Transfer-Encoding: 8bit\r\n\r\n" +
+ "Email body",
+ },
+ }
+
+ for i, test := range tests {
+ t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+ ret := rawEmail(testEmailConfig, test.item, test.id)
+ assert.Equal(t, test.result, string(ret))
+ })
+ }
+}