diff --git a/pkg/channels/email.go b/pkg/channels/email.go index 4c9c916e7..a3a801dc0 100644 --- a/pkg/channels/email.go +++ b/pkg/channels/email.go @@ -38,6 +38,8 @@ const ( reconnectBackoffMax = 10 * time.Minute // default attachment max bytes defaultAttachmentMaxBytes = 25 * 1024 * 1024 // 25MB + // max bytes to read per body part (text/plain, text/html) to avoid unbounded io.ReadAll + defaultBodyPartMaxBytes = 1 * 1024 * 1024 // 1MB ) type EmailChannel struct { @@ -764,10 +766,19 @@ func (c *EmailChannel) extractEmailBodyAndAttachments(msg *imap.Message) (conten continue } - body, err := io.ReadAll(p.Body) + limit := int64(c.config.BodyPartMaxBytes) + if limit <= 0 { + limit = int64(defaultBodyPartMaxBytes) + } + limitedBody := io.LimitReader(p.Body, limit+1) + body, err := io.ReadAll(limitedBody) if err != nil || len(body) == 0 { continue } + if len(body) > int(limit) { + textParts = append(textParts, fmt.Sprintf("[body part exceeds size limit (max %d bytes), you can check body_part_max_bytes in config]", limit)) + continue + } bodyStr := strings.TrimSpace(string(body)) if bodyStr == "" { continue diff --git a/pkg/channels/email_test.go b/pkg/channels/email_test.go index b9dc6b469..3086da457 100644 --- a/pkg/channels/email_test.go +++ b/pkg/channels/email_test.go @@ -112,36 +112,37 @@ func TestEmailChannel_extractEmailBodyAndAttachments(t *testing.T) { assert.Empty(t, paths) }) + attachmentwithText := []byte( + "From: a@b.com\r\n" + + "To: c@d.com\r\n" + + "Subject: test-file-and-body\r\n" + + "Content-Type: multipart/mixed; boundary=\"outer\"\r\n" + + "MIME-Version: 1.0\r\n" + + "\r\n" + + "--outer\r\n" + + "Content-Type: multipart/alternative; boundary=\"alt\"\r\n" + + "\r\n" + + "--alt\r\n" + + "Content-Type: text/plain; charset=GBK\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this body\r\n" + + "--alt\r\n" + + "Content-Type: text/html; charset=GBK\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "
this body
\r\n" + + "--alt--\r\n" + + "\r\n" + + "--outer\r\n" + + "Content-Type: text/plain; name=test.txt\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "Content-Disposition: attachment; filename=\"test.txt\"\r\n" + + "\r\n" + + "VGVzdC0xMTEx\r\n" + + "--outer--\r\n") t.Run("attachment and text body", func(t *testing.T) { - mimeBytes := []byte( - "From: a@b.com\r\n" + - "To: c@d.com\r\n" + - "Subject: test-file-and-body\r\n" + - "Content-Type: multipart/mixed; boundary=\"outer\"\r\n" + - "MIME-Version: 1.0\r\n" + - "\r\n" + - "--outer\r\n" + - "Content-Type: multipart/alternative; boundary=\"alt\"\r\n" + - "\r\n" + - "--alt\r\n" + - "Content-Type: text/plain; charset=GBK\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "this body\r\n" + - "--alt\r\n" + - "Content-Type: text/html; charset=GBK\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "
this body
\r\n" + - "--alt--\r\n" + - "\r\n" + - "--outer\r\n" + - "Content-Type: text/plain; name=test.txt\r\n" + - "Content-Transfer-Encoding: base64\r\n" + - "Content-Disposition: attachment; filename=\"test.txt\"\r\n" + - "\r\n" + - "VGVzdC0xMTEx\r\n" + - "--outer--\r\n") + mimeBytes := attachmentwithText section := &imap.BodySectionName{} msg := &imap.Message{ Uid: 1, @@ -155,6 +156,28 @@ func TestEmailChannel_extractEmailBodyAndAttachments(t *testing.T) { assert.Contains(t, paths[0], filepath.Base(paths[0])) }) + newLimitClient := &EmailChannel{ + config: config.EmailConfig{ + BodyPartMaxBytes: 1, + AttachmentDir: t.TempDir(), + AttachmentMaxBytes: 1024, + }, + } + t.Run("body part max bytes", func(t *testing.T) { + + mimeBytes := attachmentwithText + section := &imap.BodySectionName{} + msg := &imap.Message{ + Uid: 1, + Envelope: &imap.Envelope{Subject: "Test"}, + Body: map[*imap.BodySectionName]imap.Literal{section: bytes.NewReader(mimeBytes)}, + } + content, paths := newLimitClient.extractEmailBodyAndAttachments(msg) + assert.Contains(t, content, "[body part exceeds size limit (max 1 bytes), you can check body_part_max_bytes in config]") + assert.NotEmpty(t, paths) + assert.Equal(t, 1, len(paths)) + assert.Contains(t, paths[0], filepath.Base(paths[0])) + }) } func TestEmailChannel_extractTextFromHTML(t *testing.T) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 3277d9d06..4b2eeafe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -163,6 +163,7 @@ type EmailConfig struct { AttachmentDir string `json:"attachment_dir" env:"PICOCLAW_CHANNELS_EMAIL_ATTACHMENT_DIR"` // max size per attachment (default 25*1024*1024(25MB)), 0 = use default AttachmentMaxBytes int `json:"attachment_max_bytes" env:"PICOCLAW_CHANNELS_EMAIL_ATTACHMENT_MAX_BYTES"` // max size per attachment (default 25MB), 0 = use default + BodyPartMaxBytes int `json:"body_part_max_bytes" env:"PICOCLAW_CHANNELS_EMAIL_BODY_PART_MAX_BYTES"` // max size per body part (text/plain, text/html) to avoid unbounded io.ReadAll (default 1MB), 0 = use default // SMTP send (optional, if not configured, Send is not available) SMTPServer string `json:"smtp_server" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_SERVER"` SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"` // 465 或 587