ultisuite-backend/internal/api/mail/validate.go
R3D347HR4Y 95196f7777 Add mail attachment and draft management features
- Introduced new functionality for managing email attachments and drafts in the mail API.
- Added handlers for listing, uploading, and downloading message attachments in `internal/api/mail/handlers_attachments.go`.
- Implemented draft management endpoints for creating, updating, and deleting drafts in `internal/api/mail/handlers_drafts.go`.
- Created new service methods for handling draft and attachment operations in `internal/api/mail/drafts.go` and `internal/api/mail/storage.go`.
- Added validation and error handling for draft and attachment operations.
- Included unit tests for draft and folder functionalities in `internal/api/mail/drafts_test.go` and `internal/api/mail/folders_test.go`.
- Updated API routes to support new draft and attachment features, enhancing overall mail management capabilities.
2026-05-22 17:14:36 +02:00

489 lines
14 KiB
Go

package mail
import (
"encoding/json"
"mime"
"net"
"net/mail"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
)
const (
maxAccountRequestBody = 32 << 10 // 32 KiB
maxSendRequestBody = 5 << 20 // 5 MiB
maxWebhookRequestBody = 128 << 10 // 128 KiB
maxRulesRequestBody = 256 << 10 // 256 KiB
maxFlagsLabelsBody = 32 << 10 // 32 KiB
maxWebhookBodyTemplate = 64 << 10 // 64 KiB
maxWebhookHeaders = 20
maxHeaderNameLen = 256
maxHeaderValueLen = 8192
maxSubjectLen = 998
maxBodyField = 4 << 20 // 4 MiB per body field
maxEmailLen = 320
maxHostLen = 253
maxAccountName = 128
maxUsernameLen = 256
maxPasswordLen = 256
maxWebhookName = 128
maxRuleName = 128
)
var allowedWebhookMethods = map[string]struct{}{
"POST": {},
"PUT": {},
"PATCH": {},
}
func containsNewline(s string) bool {
return strings.ContainsAny(s, "\r\n")
}
func validateEmailField(field, addr string) *apivalidate.FieldDetail {
addr = strings.TrimSpace(addr)
if addr == "" {
return &apivalidate.FieldDetail{Field: field, Message: "required"}
}
if len(addr) > maxEmailLen {
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
}
if containsNewline(addr) {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
parsed, err := mail.ParseAddress(addr)
if err != nil || parsed.Address == "" {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
return nil
}
func validateHostField(field, host string) *apivalidate.FieldDetail {
host = strings.TrimSpace(host)
if host == "" {
return &apivalidate.FieldDetail{Field: field, Message: "required"}
}
if len(host) > maxHostLen {
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
}
if containsNewline(host) || strings.Contains(host, " ") {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
inner := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
if ip := net.ParseIP(inner); ip != nil {
return nil
}
}
if !isDNSHostname(host) {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
return nil
}
func isDNSHostname(host string) bool {
if strings.HasSuffix(host, ".") {
host = strings.TrimSuffix(host, ".")
}
if host == "" {
return false
}
labels := strings.Split(host, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, r := range label {
if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') {
return false
}
}
}
return true
}
func validatePortField(field string, port int) *apivalidate.FieldDetail {
if port < 1 || port > 65535 {
return &apivalidate.FieldDetail{Field: field, Message: "must be between 1 and 65535"}
}
return nil
}
func validateCredentialField(field, value string, maxLen int) *apivalidate.FieldDetail {
if strings.TrimSpace(value) == "" {
return &apivalidate.FieldDetail{Field: field, Message: "required"}
}
if len(value) > maxLen {
return &apivalidate.FieldDetail{Field: field, Message: "too long"}
}
if containsNewline(value) {
return &apivalidate.FieldDetail{Field: field, Message: "invalid"}
}
return nil
}
type createAccountRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Provider string `json:"provider"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
IMAPTLS bool `json:"imap_tls"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPTLS bool `json:"smtp_tls"`
Username string `json:"username"`
Password string `json:"password"`
}
func validateCreateAccount(req *createAccountRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
if req.Name != "" && len(req.Name) > maxAccountName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if d := validateEmailField("email", req.Email); d != nil {
details = append(details, *d)
}
if d := validateHostField("imap_host", req.IMAPHost); d != nil {
details = append(details, *d)
}
if d := validateHostField("smtp_host", req.SMTPHost); d != nil {
details = append(details, *d)
}
if d := validatePortField("imap_port", req.IMAPPort); d != nil {
details = append(details, *d)
}
if d := validatePortField("smtp_port", req.SMTPPort); d != nil {
details = append(details, *d)
}
if d := validateCredentialField("username", req.Username, maxUsernameLen); d != nil {
details = append(details, *d)
}
if d := validateCredentialField("password", req.Password, maxPasswordLen); d != nil {
details = append(details, *d)
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
type sendMessageRequest struct {
AccountID string `json:"account_id"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
InReplyTo string `json:"in_reply_to"`
ReplyToMessageID string `json:"reply_to_message_id"`
ScheduleAt *string `json:"schedule_at"`
}
func validateSendMessage(req *sendMessageRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
if strings.TrimSpace(req.AccountID) == "" {
details = append(details, apivalidate.FieldDetail{Field: "account_id", Message: "required"})
}
recipients := append(append([]string{}, req.To...), append(req.Cc, req.Bcc...)...)
if len(recipients) == 0 {
details = append(details, apivalidate.FieldDetail{Field: "to", Message: "at least one recipient required"})
}
for i, addr := range req.To {
if d := validateRecipient(addr); d != nil {
d.Field = "to[" + strconv.Itoa(i) + "]"
details = append(details, *d)
}
}
for i, addr := range req.Cc {
if d := validateRecipient(addr); d != nil {
d.Field = "cc[" + strconv.Itoa(i) + "]"
details = append(details, *d)
}
}
for i, addr := range req.Bcc {
if d := validateRecipient(addr); d != nil {
d.Field = "bcc[" + strconv.Itoa(i) + "]"
details = append(details, *d)
}
}
if len(req.Subject) > maxSubjectLen {
details = append(details, apivalidate.FieldDetail{Field: "subject", Message: "too long"})
}
if len(req.BodyText) > maxBodyField {
details = append(details, apivalidate.FieldDetail{Field: "body_text", Message: "too long"})
}
if len(req.BodyHTML) > maxBodyField {
details = append(details, apivalidate.FieldDetail{Field: "body_html", Message: "too long"})
}
if req.InReplyTo != "" && len(req.InReplyTo) > 998 {
details = append(details, apivalidate.FieldDetail{Field: "in_reply_to", Message: "too long"})
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
func validateRecipient(addr string) *apivalidate.FieldDetail {
addr = strings.TrimSpace(addr)
if addr == "" {
return &apivalidate.FieldDetail{Field: "email", Message: "required"}
}
if len(addr) > maxEmailLen || containsNewline(addr) {
return &apivalidate.FieldDetail{Field: "email", Message: "invalid"}
}
parsed, err := mail.ParseAddress(addr)
if err != nil || parsed.Address == "" {
return &apivalidate.FieldDetail{Field: "email", Message: "invalid"}
}
return nil
}
type updateLabelsRequest struct {
Labels []string `json:"labels"`
}
func validateUpdateLabels(req *updateLabelsRequest) *apivalidate.ValidationError {
if req.Labels == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "labels", Message: "required",
})
}
for i, label := range req.Labels {
if containsNewline(label) {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "labels[" + strconv.Itoa(i) + "]", Message: "invalid",
})
}
}
return nil
}
type updateFlagsRequest struct {
Flags []string `json:"flags"`
}
func validateUpdateFlags(req *updateFlagsRequest) *apivalidate.ValidationError {
if req.Flags == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "flags", Message: "required",
})
}
for i, flag := range req.Flags {
if strings.TrimSpace(flag) == "" || containsNewline(flag) {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "flags[" + strconv.Itoa(i) + "]", Message: "invalid",
})
}
}
return nil
}
type createRuleRequest struct {
Name string `json:"name"`
AccountID string `json:"account_id"`
Priority int `json:"priority"`
Conditions any `json:"conditions"`
Actions any `json:"actions"`
}
func validateCreateRule(req *createRuleRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
if strings.TrimSpace(req.Name) == "" {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
} else if len(req.Name) > maxRuleName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if req.Conditions == nil {
details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"})
}
if req.Actions == nil {
details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"})
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
type updateRuleRequest struct {
Name string `json:"name"`
Priority int `json:"priority"`
IsActive bool `json:"is_active"`
Conditions any `json:"conditions"`
Actions any `json:"actions"`
}
func validateUpdateRule(req *updateRuleRequest) *apivalidate.ValidationError {
var details []apivalidate.FieldDetail
if strings.TrimSpace(req.Name) == "" {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
} else if len(req.Name) > maxRuleName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if req.Conditions == nil {
details = append(details, apivalidate.FieldDetail{Field: "conditions", Message: "required"})
}
if req.Actions == nil {
details = append(details, apivalidate.FieldDetail{Field: "actions", Message: "required"})
}
if len(details) == 0 {
return nil
}
return apivalidate.NewValidationError(details...)
}
type createWebhookRequest struct {
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
BodyTemplate string `json:"body_template"`
}
func validateWebhookURL(raw string) *apivalidate.FieldDetail {
raw = strings.TrimSpace(raw)
if raw == "" {
return &apivalidate.FieldDetail{Field: "url", Message: "required"}
}
if len(raw) > 2048 {
return &apivalidate.FieldDetail{Field: "url", Message: "too long"}
}
if containsNewline(raw) {
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
}
u, err := url.Parse(raw)
if err != nil {
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return &apivalidate.FieldDetail{Field: "url", Message: "invalid scheme"}
}
if u.Host == "" {
return &apivalidate.FieldDetail{Field: "url", Message: "invalid"}
}
if u.User != nil {
return &apivalidate.FieldDetail{Field: "url", Message: "must not contain credentials"}
}
return nil
}
func validateWebhookMethod(method string) (string, *apivalidate.FieldDetail) {
method = strings.TrimSpace(strings.ToUpper(method))
if method == "" {
return "", &apivalidate.FieldDetail{Field: "method", Message: "required"}
}
if _, ok := allowedWebhookMethods[method]; !ok {
return "", &apivalidate.FieldDetail{Field: "method", Message: "invalid"}
}
return method, nil
}
func isValidHeaderToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if c < 0x21 || c == 0x7f || c == '(' || c == ')' || c == '<' || c == '>' ||
c == '@' || c == ',' || c == ';' || c == ':' || c == '\\' || c == '"' ||
c == '/' || c == '[' || c == ']' || c == '?' || c == '=' || c == '{' ||
c == '}' || c == ' ' || c == '\t' {
return false
}
}
return true
}
func validateWebhookHeaders(headers map[string]string) *apivalidate.ValidationError {
if len(headers) > maxWebhookHeaders {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "too many entries",
})
}
for name, value := range headers {
if len(name) > maxHeaderNameLen {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "header name too long",
})
}
if len(value) > maxHeaderValueLen {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "header value too long",
})
}
if containsNewline(name) || containsNewline(value) {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "invalid header",
})
}
if !isValidHeaderToken(name) {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "invalid header name",
})
}
if strings.EqualFold(name, "Content-Type") {
if _, _, err := mime.ParseMediaType(value); err != nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "headers", Message: "invalid content-type",
})
}
}
}
return nil
}
func validateWebhookBodyTemplate(body string) *apivalidate.FieldDetail {
if body == "" {
return nil
}
if len(body) > maxWebhookBodyTemplate {
return &apivalidate.FieldDetail{Field: "body_template", Message: "too large"}
}
if !json.Valid([]byte(body)) {
return &apivalidate.FieldDetail{Field: "body_template", Message: "invalid json"}
}
return nil
}
func validateCreateWebhook(req *createWebhookRequest) (string, *apivalidate.ValidationError) {
var details []apivalidate.FieldDetail
if strings.TrimSpace(req.Name) == "" {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "required"})
} else if len(req.Name) > maxWebhookName {
details = append(details, apivalidate.FieldDetail{Field: "name", Message: "too long"})
}
if d := validateWebhookURL(req.URL); d != nil {
details = append(details, *d)
}
method, d := validateWebhookMethod(req.Method)
if d != nil {
details = append(details, *d)
}
if len(details) > 0 {
return "", apivalidate.NewValidationError(details...)
}
if verr := validateWebhookHeaders(req.Headers); verr != nil {
return "", verr
}
if d := validateWebhookBodyTemplate(req.BodyTemplate); d != nil {
return "", apivalidate.NewValidationError(*d)
}
return method, nil
}