package mail import ( "encoding/json" "mime" "net" "net/mail" "net/url" "strconv" "strings" "time" "unicode" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/mail/limits" ) const ( maxAccountRequestBody = 32 << 10 // 32 KiB maxWebhookRequestBody = 128 << 10 // 128 KiB maxRulesRequestBody = 256 << 10 // 256 KiB maxFlagsLabelsBody = 32 << 10 // 32 KiB maxOutboxScheduleBody = 4 << 10 // 4 KiB maxWebhookBodyTemplate = 64 << 10 // 64 KiB maxWebhookHeaders = 20 maxHeaderNameLen = 256 maxHeaderValueLen = 8192 maxSubjectLen = 998 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"` IdempotencyKey string `json:"-"` } type rescheduleOutboxRequest struct { ScheduleAt string `json:"schedule_at"` } func validateRescheduleOutbox(req *rescheduleOutboxRequest) (*time.Time, *apivalidate.ValidationError) { scheduleAt := strings.TrimSpace(req.ScheduleAt) if scheduleAt == "" { return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "required"}) } parsed, err := time.Parse(time.RFC3339, scheduleAt) if err != nil { return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "invalid"}) } if !parsed.After(time.Now()) { return nil, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "schedule_at", Message: "must be in the future"}) } return &parsed, nil } 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) > limits.MaxBodyFieldBytes { details = append(details, apivalidate.FieldDetail{Field: "body_text", Message: "too long"}) } if len(req.BodyHTML) > limits.MaxBodyFieldBytes { 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 }