|
3 | 3 |
|
4 | 4 | package main |
5 | 5 |
|
6 | | -import "context" |
| 6 | +import ( |
| 7 | + "bytes" |
| 8 | + "context" |
| 9 | + "fmt" |
| 10 | + "net/smtp" |
| 11 | + "strconv" |
| 12 | + "strings" |
7 | 13 |
|
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 | +) |
10 | 18 |
|
11 | 19 | 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 |
12 | 33 | } |
13 | 34 |
|
14 | 35 | // Send constructs a raw email from EmailToSend and sends it over SMTP. |
15 | 36 | 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) |
22 | 146 | } |
0 commit comments