diff --git a/cmd/email.go b/cmd/email.go index 76d75d3..15218ee 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -4,7 +4,9 @@ Copyright © 2025 canaria-computer package cmd import ( + "bufio" "fmt" + "math/rand" "os" "path/filepath" "sort" @@ -12,34 +14,35 @@ import ( "github.com/canaria-computer/down-force/internal/appconfig" "github.com/canaria-computer/down-force/internal/email" + "github.com/canaria-computer/down-force/internal/utils" "github.com/charmbracelet/glamour" + "github.com/charmbracelet/huh" "github.com/charmbracelet/log" "github.com/spf13/cobra" mail "github.com/xhit/go-simple-mail/v2" ) var ( - // Persistent flags for email command emailReportDir string - // Security flags for email command emailIgnoreTLSErrors bool emailAllowUnsafeHTML bool - // IMAP timeout override flag emailIMAPTimeout int + emailSMTPTimeout int - // Flags for draft subcommand emailDraftUpload bool emailDraftExport bool emailDraftPreview bool emailDraftPlainText bool - // Flags for send-with-confirm subcommand emailSendNoMessageID bool + emailSendNoConfirm bool + + emailResendEditRecipients bool + emailResendRegenerateID bool ) -// emailCmd represents the email command var emailCmd = &cobra.Command{ Use: "email", Short: "Generate and send emails based on evidence reports", @@ -54,6 +57,7 @@ By default, the command automatically selects the most recent report directory Available subcommands: draft Create email drafts (upload to IMAP, export .eml, or preview) send-with-confirm Send email after user confirmation + resend Resend a previously saved .eml file Security Options: --ignore-tls-errors Disable TLS certificate verification (development only) @@ -91,7 +95,6 @@ Configuration Precedence: }, } -// emailDraftCmd represents the draft subcommand var emailDraftCmd = &cobra.Command{ Use: "draft", Short: "Create email draft from evidence report", @@ -147,22 +150,39 @@ Configuration: Run: runEmailDraft, } -// emailSendCmd represents the send-with-confirm subcommand var emailSendCmd = &cobra.Command{ Use: "send-with-confirm", Short: "Send email after confirmation", Long: `Generate email from evidence report and send it after user confirmation. -This command: - 1. Generates the email from the evidence report - 2. Displays a preview of the email content - 3. Prompts for user confirmation - 4. Sends the email via SMTP if confirmed - 5. Optionally sets Message-ID header for tracking +This command supports two input modes: + +1. Interactive Mode (default): + - Prompts for recipients using an interactive form + - From/Reply-To are pre-filled from config (skippable but navigable) + - To/CC/BCC support multiple addresses (one per line or semicolon-separated) + - Displays email preview before sending + - Requires user confirmation before sending + +2. Stdin Mode (for CI/automation): + - Detects piped input automatically + - Reads recipients in format: to:addr@example.com (one per line) + - Supported prefixes: to:, cc:, bcc: + - Skips confirmation prompt automatically + - Example: + echo -e "to:user@example.com\ncc:other@example.com" | down-force email send-with-confirm The Message-ID header is automatically generated by default for email tracking and threading. Use --no-set-message-id to skip Message-ID generation and let the mail server assign one. +If sending fails, the email is automatically saved as report.eml in the report directory. +Use the 'resend' subcommand to retry sending the saved .eml file. + +Flags: + --no-confirm Skip confirmation prompt (stdin mode auto-skips) + --no-set-message-id Skip Message-ID generation + --smtp-timeout SMTP connection timeout (default: config or 30s) + Security Options: --ignore-tls-errors Disable TLS certificate verification (SMTP) WARNING: Only use in development with self-signed certs @@ -170,9 +190,23 @@ Security Options: WARNING: Only use with trusted HTML sources Examples: - # Send email with confirmation (default behavior) + # Interactive mode (default) down-force email send-with-confirm + # Stdin mode for CI/automation + echo "to:user@example.com" | down-force email send-with-confirm + + # Stdin mode with heredoc (multiple recipients) + down-force email send-with-confirm <", + Short: "Resend a previously saved .eml file", + Long: `Resend an email from a previously saved .eml file. + +This command loads an existing .eml file (typically report.eml from a failed send) +and attempts to resend it via SMTP. + +Options: + --edit-recipients Interactively edit recipients before sending + --regenerate-message-id Generate a new Message-ID (default: keep original) + --no-confirm Skip confirmation prompt + +By default, the original recipients and Message-ID are preserved. +Use --edit-recipients to modify the recipient list interactively. +Use --regenerate-message-id to create a new Message-ID for the resend. + +Security Options: + --ignore-tls-errors Disable TLS certificate verification (SMTP) + WARNING: Only use in development with self-signed certs + +Examples: + # Resend with original recipients and Message-ID + down-force email resend report-20240101-120000/report.eml + + # Resend with edited recipients + down-force email resend report.eml --edit-recipients + + # Resend with new Message-ID + down-force email resend report.eml --regenerate-message-id + + # Resend with all modifications + down-force email resend report.eml --edit-recipients --regenerate-message-id + +Configuration: + SMTP server settings must be configured before sending. + Use 'down-force config email' for SMTP setup.`, + Args: cobra.ExactArgs(1), + Run: runEmailResend, +} + func init() { rootCmd.AddCommand(emailCmd) emailCmd.AddCommand(emailDraftCmd) emailCmd.AddCommand(emailSendCmd) + emailCmd.AddCommand(emailResendCmd) - // Persistent flags (available to all email subcommands) emailCmd.PersistentFlags().StringVar(&emailReportDir, "report", "", "Report directory to use (default: latest report-* directory)") - // Security flags (available to all email subcommands) emailCmd.PersistentFlags().BoolVar(&emailIgnoreTLSErrors, "ignore-tls-errors", false, "Disable TLS certificate verification (DEVELOPMENT ONLY - insecure)") emailCmd.PersistentFlags().BoolVar(&emailAllowUnsafeHTML, "allow-unsafe-html", false, "Allow unsafe HTML embedding without sanitization (DEVELOPMENT ONLY - XSS risk)") - // IMAP timeout override flag emailCmd.PersistentFlags().IntVar(&emailIMAPTimeout, "imap-timeout", 0, "IMAP connection timeout in seconds (default: uses config value or 30s)") - // Draft subcommand flags + emailCmd.PersistentFlags().IntVar(&emailSMTPTimeout, "smtp-timeout", 0, + "SMTP connection timeout in seconds (default: uses config value or 30s)") + emailDraftCmd.Flags().BoolVar(&emailDraftUpload, "upload", false, "Upload draft to IMAP Drafts folder") emailDraftCmd.Flags().BoolVar(&emailDraftExport, "export", false, @@ -223,20 +298,25 @@ func init() { emailDraftCmd.Flags().BoolVar(&emailDraftPlainText, "plain-text-only", false, "Generate plain-text only email (no HTML)") - // Send subcommand flags emailSendCmd.Flags().BoolVar(&emailSendNoMessageID, "no-set-message-id", false, "Skip Message-ID generation (let server assign)") + emailSendCmd.Flags().BoolVar(&emailSendNoConfirm, "no-confirm", false, + "Skip confirmation prompt") + + emailResendCmd.Flags().BoolVar(&emailResendEditRecipients, "edit-recipients", false, + "Interactively edit recipients before sending") + emailResendCmd.Flags().BoolVar(&emailResendRegenerateID, "regenerate-message-id", false, + "Generate a new Message-ID (default: keep original)") + emailResendCmd.Flags().BoolVar(&emailSendNoConfirm, "no-confirm", false, + "Skip confirmation prompt") } func runEmailDraft(cmd *cobra.Command, args []string) { - // Load application configuration cfg := appconfig.GetConfig() - // Apply security settings (CLI flags override config file) effectiveIgnoreTLS := emailIgnoreTLSErrors || cfg.Email.Security.IgnoreTLSErrors effectiveAllowHTML := emailAllowUnsafeHTML || cfg.Email.Security.AllowUnsafeHTML - // Display security configuration if effectiveIgnoreTLS { log.Warn("TLS certificate verification is DISABLED") } @@ -244,7 +324,6 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Warn("Unsafe HTML embedding is ENABLED") } - // Determine report directory reportDir, err := getReportDirectory(emailReportDir) if err != nil { log.Fatalf("Failed to determine report directory: %v", err) @@ -252,13 +331,11 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Infof("Using report directory: %s", reportDir) - // Determine output mode (default to export + upload if none specified) if !emailDraftUpload && !emailDraftExport && !emailDraftPreview { emailDraftExport = true emailDraftUpload = true } - // Validate that only one mode is selected (unless default behavior) modeCount := 0 if emailDraftUpload { modeCount++ @@ -274,20 +351,16 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Fatal("Error: Only one or two output modes can be specified") } - // Load report content reportContent, err := email.LoadReportContent(reportDir) if err != nil { log.Fatalf("Failed to load report content: %v", err) } - // Build email message (needed for both export and upload) includeHTML := !emailDraftPlainText msg, err := email.BuildEmailMessage(reportContent, includeHTML, emailDraftExport) if err != nil { log.Fatalf("Failed to build email message: %v", err) } - - // Execute based on selected mode(s) if emailDraftExport { log.Info("Exporting draft as .eml file...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) @@ -303,7 +376,6 @@ func runEmailDraft(cmd *cobra.Command, args []string) { log.Info("Uploading draft to IMAP Drafts folder...") log.Infof("TLS Error Handling: %v", effectiveIgnoreTLS) - // Validate IMAP configuration if cfg.Email.IMAP.URI == "" { log.Error("IMAP URI is not configured") log.Info("Please configure IMAP settings using:") @@ -313,7 +385,6 @@ func runEmailDraft(cmd *cobra.Command, args []string) { return } - // Connect to IMAP server (authentication credentials only, no placeholders) client, err := email.ConnectIMAP(cfg.Email.IMAP, effectiveIgnoreTLS, emailIMAPTimeout) if err != nil { log.Errorf("Failed to connect to IMAP server: %v", err) @@ -326,22 +397,18 @@ func runEmailDraft(cmd *cobra.Command, args []string) { } }() - // Find or create Drafts mailbox draftFolder, err := email.FindOrCreateDraftMailbox(client, cfg.Email.IMAP.DraftFolder) if err != nil { log.Errorf("Failed to find or create Drafts mailbox: %v", err) return } - // Upload draft message if err := email.UploadDraftMessage(client, msg, draftFolder); err != nil { log.Errorf("Failed to upload draft message: %v", err) return } log.Info("Draft uploaded successfully to IMAP server") - - // Logout if err := client.Logout().Wait(); err != nil { log.Warnf("IMAP logout warning: %v", err) } @@ -368,6 +435,59 @@ func saveEmlFile(msg *mail.Email, reportDir string) error { return nil } +func previewEmail(msg *mail.Email, recipients email.Recipients, bodyMarkdown string) { + log.Info("=== Email Preview ===") + + msgStr := msg.GetMessage() + headers := parseEmailHeaders(msgStr) + + if subject := headers["Subject"]; subject != "" { + log.Infof("Subject: %s", subject) + } + if from := headers["From"]; from != "" { + log.Infof("From: %s", from) + } + if replyTo := headers["Reply-To"]; replyTo != "" { + log.Infof("Reply-To: %s", replyTo) + } + + if len(recipients.To) > 0 { + log.Infof("To: %s", strings.Join(recipients.To, ", ")) + } + if len(recipients.Cc) > 0 { + log.Infof("Cc: %s", strings.Join(recipients.Cc, ", ")) + } + if len(recipients.Bcc) > 0 { + log.Infof("Bcc: %s", strings.Join(recipients.Bcc, ", ")) + } + + log.Info("") + out, err := glamour.Render(bodyMarkdown, "dark") + if err != nil { + log.Errorf("Failed to render markdown preview: %v", err) + return + } + fmt.Println(out) +} + +func parseEmailHeaders(msgStr string) map[string]string { + headers := make(map[string]string) + lines := strings.Split(msgStr, "\n") + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + break + } + if idx := strings.Index(line, ":"); idx > 0 { + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + headers[key] = value + } + } + + return headers +} + func displayEmailPreview(content string) { out, err := glamour.Render(content, "dark") if err != nil { @@ -377,6 +497,151 @@ func displayEmailPreview(content string) { fmt.Println(out) } +func runEmailResend(cmd *cobra.Command, args []string) { + cfg := appconfig.GetConfig() + + effectiveIgnoreTLS := emailIgnoreTLSErrors || cfg.Email.Security.IgnoreTLSErrors + + if effectiveIgnoreTLS { + log.Warn("TLS certificate verification is DISABLED") + } + + emlPath := args[0] + log.Infof("Loading email from: %s", emlPath) + + msg, recipients, err := email.LoadEmailFromEML(emlPath) + if err != nil { + log.Fatalf("Failed to load .eml file: %v", err) + } + + log.Info("Email loaded successfully") + + if emailResendEditRecipients { + log.Info("Editing recipients...") + recipients, _, _, err = promptRecipients() + if err != nil { + log.Fatalf("Failed to edit recipients: %v", err) + } + } + if emailResendRegenerateID { + log.Info("Regenerating Message-ID...") + + msgStr := msg.GetMessage() + headers := parseEmailHeaders(msgStr) + fromAddr := headers["From"] + + senderUsername := "user" + senderDomain := "example.com" + if fromAddr != "" { + senderParts := strings.Split(fromAddr, "@") + if len(senderParts) == 2 { + senderUsername = senderParts[0] + senderDomain = senderParts[1] + } + } + + subject := headers["Subject"] + reportID := "RESEND" + if idx := strings.Index(subject, "#CaseId="); idx > 0 { + reportID = subject[idx+8:] + if endIdx := strings.IndexAny(reportID, " ]"); endIdx > 0 { + reportID = reportID[:endIdx] + } + } + + random := generateRandomString(7) + newMessageID := fmt.Sprintf("<%s#%s%%%s@%s>", senderUsername, reportID, random, senderDomain) + msg.AddHeader("Message-ID", newMessageID) + log.Infof("New Message-ID: %s", newMessageID) + } + msgStr := msg.GetMessage() + bodyMarkdown := extractBodyFromMessage(msgStr) + + previewEmail(msg, recipients, bodyMarkdown) + if !emailSendNoConfirm { + var confirmed bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Resend Email?"). + Description("Do you want to resend this email now?"). + Value(&confirmed), + ), + ).WithTheme(huh.ThemeDracula()) + + if err := confirmForm.Run(); err != nil { + log.Fatal("Confirmation cancelled") + } + + if !confirmed { + log.Info("Email resend cancelled by user") + return + } + } + + // Validate SMTP configuration + if cfg.Email.SMTP.URI == "" { + log.Error("SMTP URI is not configured") + log.Info("Please configure SMTP settings using:") + log.Info(" down-force config init") + log.Info("Or set environment variable:") + log.Info(" export DOWNFORCE_EMAIL_SMTP_URI='smtp://user:pass@smtp.example.com:587'") + return + } + + // Create SMTP sender + log.Info("Connecting to SMTP server...") + sender, err := email.NewSMTPSender(cfg.Email.SMTP, effectiveIgnoreTLS, emailSMTPTimeout) + if err != nil { + log.Fatalf("Failed to create SMTP sender: %v", err) + } + defer func() { + if err := sender.Close(); err != nil { + log.Warnf("Failed to close SMTP connection: %v", err) + } + }() + + // Send email + log.Info("Sending email...") + if err := sender.Send(msg, recipients); err != nil { + log.Errorf("Failed to resend email: %v", err) + os.Exit(1) + } + + log.Info("✓ Email resent successfully") +} + +func extractBodyFromMessage(msgStr string) string { + lines := strings.Split(msgStr, "\n") + bodyStart := -1 + for i, line := range lines { + if strings.TrimSpace(line) == "" { + bodyStart = i + 1 + break + } + } + + if bodyStart < 0 || bodyStart >= len(lines) { + return "" + } + + return strings.Join(lines[bodyStart:], "\n") +} + +func generateRandomString(length int) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[randomInt(0, len(charset))] + } + return string(b) +} + +func randomInt(min, max int) int { + return min + rand.Intn(max-min) +} + + func runEmailSend(cmd *cobra.Command, args []string) { cfg := appconfig.GetConfig() @@ -404,6 +669,7 @@ func runEmailSend(cmd *cobra.Command, args []string) { } includeHTML := cfg.Email.MimeType == "multipart/alternative" + log.Debugf("Email MimeType config: '%s', includeHTML: %v", cfg.Email.MimeType, includeHTML) reportContent, err := email.LoadReportContent(reportDir) if err != nil { @@ -415,16 +681,97 @@ func runEmailSend(cmd *cobra.Command, args []string) { log.Fatalf("Failed to build email message: %v", err) } - // TODO: Implement email send with confirmation and security settings - log.Warn("Email send functionality not yet implemented") - _ = msg + var recipients email.Recipients + var fromOverride, replyToOverride string + stat, _ := os.Stdin.Stat() + isStdinPipe := (stat.Mode() & os.ModeCharDevice) == 0 + + if isStdinPipe { + log.Info("Reading recipients from stdin...") + recipients, err = readRecipientsFromStdin() + if err != nil { + log.Fatalf("Failed to read recipients from stdin: %v", err) + } + emailSendNoConfirm = true + } else { + log.Info("Interactive mode: prompting for recipients...") + recipients, fromOverride, replyToOverride, err = promptRecipients() + if err != nil { + log.Fatalf("Failed to prompt for recipients: %v", err) + } + if fromOverride != "" { + msg.SetFrom(fromOverride) + } + if replyToOverride != "" { + msg.SetReplyTo(replyToOverride) + } + } + + previewEmail(msg, recipients, string(reportContent.Body)) + if !emailSendNoConfirm { + var confirmed bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Send Email?"). + Description("Do you want to send this email now?"). + Value(&confirmed), + ), + ).WithTheme(huh.ThemeDracula()) + + if err := confirmForm.Run(); err != nil { + log.Fatal("Confirmation cancelled") + } + + if !confirmed { + log.Info("Email send cancelled by user") + return + } + } + + // Validate SMTP configuration + if cfg.Email.SMTP.URI == "" { + log.Error("SMTP URI is not configured") + log.Info("Please configure SMTP settings using:") + log.Info(" down-force config init") + log.Info("Or set environment variable:") + log.Info(" export DOWNFORCE_EMAIL_SMTP_URI='smtp://user:pass@smtp.example.com:587'") + return + } + + // Create SMTP sender + log.Info("Connecting to SMTP server...") + sender, err := email.NewSMTPSender(cfg.Email.SMTP, effectiveIgnoreTLS, emailSMTPTimeout) + if err != nil { + log.Fatalf("Failed to create SMTP sender: %v", err) + } + defer func() { + if err := sender.Close(); err != nil { + log.Warnf("Failed to close SMTP connection: %v", err) + } + }() + + log.Info("Sending email...") + if err := sender.Send(msg, recipients); err != nil { + log.Errorf("Failed to send email: %v", err) + emlPath := filepath.Join(reportDir, "report.eml") + savedPath, saveErr := email.SaveEmailWithRecipients(msg, recipients, emlPath) + if saveErr != nil { + log.Errorf("Failed to save email to .eml file: %v", saveErr) + } else { + log.Infof("Email saved to: %s", savedPath) + log.Info("To resend, use:") + log.Infof(" down-force email resend %s", savedPath) + } + + os.Exit(1) + } + + log.Info("✓ Email sent successfully") } -// getReportDirectory determines which report directory to use -// If reportDir is specified, use that. Otherwise, find the latest report-* directory. func getReportDirectory(reportDir string) (string, error) { if reportDir != "" { - // User specified a directory return reportDir, nil } cwd, err := os.Getwd() @@ -481,3 +828,176 @@ func hasAnyFile(dirPath string) (bool, error) { return fileFound, err } + +func readRecipientsFromStdin() (email.Recipients, error) { + recipients := email.Recipients{} + scanner := bufio.NewScanner(os.Stdin) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return email.Recipients{}, fmt.Errorf("invalid format: %s (expected prefix:email)", line) + } + + prefix := strings.ToLower(strings.TrimSpace(parts[0])) + addr := strings.TrimSpace(parts[1]) + + if err := utils.ValidateEmailAddress(addr); err != nil { + return email.Recipients{}, fmt.Errorf("invalid email address '%s': %w", addr, err) + } + + switch prefix { + case "to": + recipients.To = append(recipients.To, addr) + case "cc": + recipients.Cc = append(recipients.Cc, addr) + case "bcc": + recipients.Bcc = append(recipients.Bcc, addr) + default: + return email.Recipients{}, fmt.Errorf("unknown prefix: %s (expected to/cc/bcc)", prefix) + } + } + + if err := scanner.Err(); err != nil { + return email.Recipients{}, fmt.Errorf("error reading stdin: %w", err) + } + + if err := recipients.Validate(); err != nil { + return email.Recipients{}, err + } + + return recipients, nil +} + +func promptRecipients() (email.Recipients, string, string, error) { + cfg := appconfig.GetConfig() + fromAddr := cfg.Email.From + if fromAddr == "" { + fromAddr = cfg.Reporter.Contact.Email + } + replyToAddr := cfg.Email.ReplyTo + + reportDir, err := getReportDirectory(emailReportDir) + if err == nil { + placeholderCtx, err := utils.NewPlaceholderContextFromReportDir(reportDir) + if err == nil { + if fromAddr != "" { + expandedFrom, err := utils.ExpandAndValidateEmailAddress(fromAddr, placeholderCtx) + if err == nil { + fromAddr = expandedFrom + } + } + if replyToAddr != "" { + expandedReplyTo, err := utils.ExpandAndValidateEmailAddress(replyToAddr, placeholderCtx) + if err == nil { + replyToAddr = expandedReplyTo + } + } + } + } + + var toInput, ccInput, bccInput string + + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Email Recipients"). + Description("Enter recipient email addresses.\n\n"+ + "- From/Reply-To are pre-filled from config (skippable but navigable)\n"+ + "- To/CC/BCC support multiple addresses (one per line or semicolon-separated)\n"+ + "- At least one recipient (To, CC, or BCC) is required"), + + huh.NewInput(). + Title("From"). + Description("Sender email address (from config)"). + Value(&fromAddr), + + huh.NewInput(). + Title("Reply-To (optional)"). + Description("Reply-To email address (from config)"). + Value(&replyToAddr), + + huh.NewText(). + Title("To"). + Description("Primary recipients (one per line or semicolon-separated)"). + Placeholder("user1@example.com\nuser2@example.com"). + CharLimit(2000). + Value(&toInput), + + huh.NewText(). + Title("CC (optional)"). + Description("Carbon copy recipients (one per line or semicolon-separated)"). + Placeholder("cc1@example.com;cc2@example.com"). + CharLimit(2000). + Value(&ccInput), + + huh.NewText(). + Title("BCC (optional)"). + Description("Blind carbon copy recipients (one per line or semicolon-separated)"). + Placeholder("bcc1@example.com\nbcc2@example.com"). + CharLimit(2000). + Value(&bccInput), + ).Title("Recipients"), + ).WithTheme(huh.ThemeDracula()) + + if err := form.Run(); err != nil { + return email.Recipients{}, "", "", fmt.Errorf("form cancelled: %w", err) + } + + recipients := email.Recipients{} + toAddrs := parseMultilineAddresses(toInput) + for _, addr := range toAddrs { + if err := utils.ValidateEmailAddress(addr); err != nil { + return email.Recipients{}, "", "", fmt.Errorf("invalid To address '%s': %w", addr, err) + } + recipients.To = append(recipients.To, addr) + } + + ccAddrs := parseMultilineAddresses(ccInput) + for _, addr := range ccAddrs { + if err := utils.ValidateEmailAddress(addr); err != nil { + return email.Recipients{}, "", "", fmt.Errorf("invalid CC address '%s': %w", addr, err) + } + recipients.Cc = append(recipients.Cc, addr) + } + + bccAddrs := parseMultilineAddresses(bccInput) + for _, addr := range bccAddrs { + if err := utils.ValidateEmailAddress(addr); err != nil { + return email.Recipients{}, "", "", fmt.Errorf("invalid BCC address '%s': %w", addr, err) + } + recipients.Bcc = append(recipients.Bcc, addr) + } + + if err := recipients.Validate(); err != nil { + return email.Recipients{}, "", "", err + } + + return recipients, fromAddr, replyToAddr, nil +} + +func parseMultilineAddresses(input string) []string { + input = strings.TrimSpace(input) + if input == "" { + return nil + } + + var addresses []string + lines := strings.Split(input, "\n") + for _, line := range lines { + parts := strings.Split(line, ";") + for _, part := range parts { + addr := strings.TrimSpace(part) + if addr != "" { + addresses = append(addresses, addr) + } + } + } + + return addresses +} diff --git a/doc/app-behavior-config.md b/doc/app-behavior-config.md index 4f83ec4..e15d9f1 100644 --- a/doc/app-behavior-config.md +++ b/doc/app-behavior-config.md @@ -116,7 +116,6 @@ Markdownメールに埋め込むHTMLコンテンツのサニタイゼーショ 悪意あるコンテンツは、配信された場合あなただけでなく他の第三者にも被害を及ぼす可能性があるほか、 あなたのドメインやメールアドレス、IPアドレスの評判が損なわれる可能性もあります。 -```` ## 2. 環境変数マッピング CI/CDパイプラインやコンテナ環境での利用を想定し、すべての設定は環境変数で定義可能です。 @@ -182,6 +181,7 @@ CI/CDパイプラインやコンテナ環境での利用を想定し、すべて - `{time}`: 時刻部分(例: `120000`) 使用例: + ```yaml email: from: "abuse+{report_id}@example.com" # → abuse+report-20240101-120000@example.com diff --git a/doc/email-smtp.md b/doc/email-smtp.md new file mode 100644 index 0000000..c03943b --- /dev/null +++ b/doc/email-smtp.md @@ -0,0 +1,642 @@ +# SMTPメール送信機能 + +`down-force`は、証拠収集レポートから生成したメールをSMTPサーバー経由で送信する機能をサポートしています。 + +## 概要 + +`email send-with-confirm`および`email resend`コマンドは、SMTPサーバーを使用してメールを送信します: + +- **send-with-confirm**: 証拠レポートから新規メールを作成して送信 +- **resend**: 保存済み.emlファイルを再送信 + +**主な機能**: + +- インタラクティブモードと標準入力モードの両対応 +- 送信前のプレビューと確認プロンプト +- 自動リトライ機能(3回、指数バックオフ) +- 送信失敗時の.eml自動保存 +- Message-ID生成によるメール追跡 + +--- + +## なぜ send-with-confirm コマンドなのか + +`down-force`は、メール送信における**誤送信リスクの最小化**と**自動化の両立**を重視して設計されています。 + +### 確認プロンプトの重要性 + +- **誤送信の防止** + CLI操作では、一度のコマンド実行が即座に送信につながります。 + 確認プロンプトを挟むことで、送信前に内容を最終確認できます。 + +- **安全な自動化** + `--no-confirm`フラグまたは標準入力モードを使用することで、 + 確認プロンプトをスキップして自動送信も可能です。 + +- **失敗時の復旧** + 送信失敗時に.emlファイルとして自動保存されるため、 + `resend`コマンドで簡単に再送信できます。 + +この設計により、**手動操作での安全性と、CI/CD環境での自動化**の両方を実現しています。 + +--- + +## SMTP URI形式 + +SMTP接続には、IMAP同様の非公式標準URI形式を使用します: + +``` +smtp://username:password@host:port +``` + +**注意**: +ユーザー名やパスワードに`@`などの特殊文字が含まれる場合は、パーセントエンコーディングが必要です。 + +### パーセントエンコーディング + +**特に注意が必要な文字**: + +- `@` → `%40` +- `:` → `%3A` +- `/` → `%2F` +- `?` → `%3F` +- `#` → `%23` + +**例**: ユーザー名が`user@example.com`の場合 + +```bash +# 誤り(@が2つあり、どちらがユーザー情報とホストの区切りか判別不可) +smtp://user@example.com:password@smtp.example.com:587 + +# 正しい(ユーザー名の@を%40にエンコード) +smtp://user%40example.com:password@smtp.example.com:587 +``` + +### URI形式の例 + +#### Gmail + +```bash +# STARTTLS(推奨) +smtp://user%40gmail.com:app-password@smtp.gmail.com:587 + +# SSL/TLS +smtps://user%40gmail.com:app-password@smtp.gmail.com:465 +``` + +**注意**: Gmailを使用する場合は、アプリパスワードが必要です。 + +#### Outlook / Microsoft 365 + +```bash +smtp://user%40outlook.com:password@smtp-mail.outlook.com:587 +``` + +--- + +## 設定方法 + +### 1. 設定ファイル + +`~/.config/down-force/config.yaml`: + +```yaml +email: + from: "reporter@example.com" + reply_to: "security@example.com" # オプション + smtp: + uri: "smtp://user%40example.com:password@smtp.example.com:587" + secure: "STARTTLS" # "SSL", "STARTTLS", "None" + auth_type: "AUTO" # "AUTO", "PLAIN", "LOGIN", "CRAM-MD5" + timeout: 30 # タイムアウト秒数(デフォルト: 30) + mime_type: "multipart/alternative" # または "text/plain" +``` + +### 2. 環境変数 + +```bash +export DOWNFORCE_EMAIL_FROM="reporter@example.com" +export DOWNFORCE_EMAIL_REPLY_TO="security@example.com" +export DOWNFORCE_EMAIL_SMTP_URI="smtp://user%40example.com:password@smtp.example.com:587" +export DOWNFORCE_EMAIL_SMTP_SECURE="STARTTLS" +export DOWNFORCE_EMAIL_SMTP_AUTH_TYPE="AUTO" +export DOWNFORCE_EMAIL_SMTP_TIMEOUT="30" +``` + +### 3. 初期設定ウィザード + +```bash +down-force config init +``` + +対話的に設定を行えます。 + +--- + +## 基本的な使い方 + +### send-with-confirm: 新規メール送信 + +#### インタラクティブモード(デフォルト) + +```bash +down-force email send-with-confirm +``` + +1. 最新のレポートディレクトリを自動検出 +2. 宛先入力フォームが表示される + - From/Reply-To: 設定から自動入力 + - To/CC/BCC: 複数アドレス入力可能(改行またはセミコロン区切り) +3. メールのプレビューが表示される +4. 送信確認プロンプトが表示される +5. 確認後、SMTP経由で送信 + +#### 標準入力モード(CI/自動化向け) + +```bash +cat < +``` + +#### Message-ID生成をスキップ + +```bash +down-force email send-with-confirm --no-set-message-id +``` + +メールサーバーがMessage-IDを割り当てます。 + +--- + +## プレビュー表示 + +送信前に2段階でプレビューが表示されます。 + +### Stage 1: ヘッダー情報 + +``` +INFO === Email Preview === +INFO Subject: Phishing Abuse [Takedown Request] | ex***le.com #CaseId=ABC123 +INFO From: reporter@example.com +INFO Reply-To: security@example.com +INFO To: recipient@example.com +INFO Cc: manager@example.com +INFO Bcc: archive@example.com +``` + +### Stage 2: メール本文 + +Markdownレンダリングされた本文がターミナルに表示されます。 + +--- + +## 送信失敗時の動作 + +### 自動.eml保存 + +送信が失敗すると、メールは自動的に`report.eml`として保存されます: + +``` +ERROR Failed to send email: connection timeout +INFO Email saved to: /path/to/report-MjAyNjAxMDgtMTc0NTE1/report.eml +INFO To resend, use: +INFO down-force email resend /path/to/report-MjAyNjAxMDgtMTc0NTE1/report.eml +``` + +### .emlファイルの内容 + +- **完全なメールヘッダー**: Subject, From, Reply-To, Date, Message-ID +- **受信者情報**: To, Cc, Bcc(すべて保存されます) +- **メール本文**: HTML/プレーンテキスト +- **添付ファイル情報**(実際のファイルは含まれない) + +### 再送信 + +```bash +down-force email resend /path/to/report.eml +``` + +元の内容をそのまま保持して再送信します。 + +--- + +## CI/CD統合 + +### GitHub Actions + +```yaml +- name: Send phishing report + run: | + cat <", senderUsername, content.ReportID, random, senderDomain) m.AddHeader("Message-ID", messageID) log.Debugf("Generated Message-ID: %s", messageID) if includeHTML { + log.Debug("Building multipart/alternative email (HTML + Plain text)") md := MarkdownRenderer() var htmlBuf bytes.Buffer if err := md.Convert(content.Body, &htmlBuf); err != nil { @@ -185,6 +176,7 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende m.SetBody(mail.TextHTML, htmlBuf.String()) m.AddAlternative(mail.TextPlain, string(content.Body)) } else { + log.Debug("Building text/plain email (Plain text only)") m.SetBody(mail.TextPlain, string(content.Body)) } @@ -198,12 +190,7 @@ func BuildEmailMessage(content *ReportContent, includeHTML bool, allowEmptySende return m, nil } -// findScreenshot searches for a screenshot file matching user agents from evidence data -// and returns the absolute file path to the screenshot. Returns an empty string if no -// screenshot file is found or accessible. The search order prioritizes user agents -// listed in evidence.UserAgents. func findScreenshot(reportDir string, evidence config.EvidenceConfig) string { - // Search for screenshot.png in each user agent directory for _, ua := range evidence.UserAgents { screenshotPath := filepath.Join(reportDir, ua.Name, "screenshot.png") if _, err := os.Stat(screenshotPath); err == nil { @@ -216,7 +203,6 @@ func findScreenshot(reportDir string, evidence config.EvidenceConfig) string { return "" } -// generateRandomString generates a random alphanumeric string func generateRandomString(length int) string { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, length) @@ -226,7 +212,122 @@ func generateRandomString(length int) string { return string(b) } -// randomInt generates a random integer between min and max (exclusive) func randomInt(min, max int) int { return min + rand.Intn(max-min) } + +func SaveEmailWithRecipients(msg *mail.Email, recipients Recipients, outputPath string) (string, error) { + if err := AddRecipientsToMessage(msg, recipients); err != nil { + return "", fmt.Errorf("failed to add recipients: %w", err) + } + + emlBytes := msg.GetMessage() + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Write to file + if err := os.WriteFile(outputPath, []byte(emlBytes), 0644); err != nil { + return "", fmt.Errorf("failed to write EML file: %w", err) + } + + absPath, err := filepath.Abs(outputPath) + if err != nil { + log.Warnf("failed to get absolute path: %v", err) + absPath = outputPath + } + + log.Infof("Email saved to %s", absPath) + return absPath, nil +} + +func LoadEmailFromEML(emlPath string) (*mail.Email, Recipients, error) { + file, err := os.Open(emlPath) + if err != nil { + return nil, Recipients{}, fmt.Errorf("failed to open EML file: %w", err) + } + defer func() { + if err := file.Close(); err != nil { + log.Warnf("failed to close EML file: %v", err) + } + }() + + msg, err := netmail.ReadMessage(file) + if err != nil { + return nil, Recipients{}, fmt.Errorf("failed to parse EML file: %w", err) + } + + recipients := Recipients{} + if toHeader := msg.Header.Get("To"); toHeader != "" { + addresses, err := netmail.ParseAddressList(toHeader) + if err == nil { + for _, addr := range addresses { + recipients.To = append(recipients.To, addr.Address) + } + } + } + + if ccHeader := msg.Header.Get("Cc"); ccHeader != "" { + addresses, err := netmail.ParseAddressList(ccHeader) + if err == nil { + for _, addr := range addresses { + recipients.Cc = append(recipients.Cc, addr.Address) + } + } + } + + if bccHeader := msg.Header.Get("Bcc"); bccHeader != "" { + addresses, err := netmail.ParseAddressList(bccHeader) + if err == nil { + for _, addr := range addresses { + recipients.Bcc = append(recipients.Bcc, addr.Address) + } + } + } + + bodyBytes, err := io.ReadAll(msg.Body) + if err != nil { + return nil, Recipients{}, fmt.Errorf("failed to read email body: %w", err) + } + + m := mail.NewMSG() + if from := msg.Header.Get("From"); from != "" { + addresses, err := netmail.ParseAddressList(from) + if err == nil && len(addresses) > 0 { + m.SetFrom(addresses[0].Address) + } + } + + if replyTo := msg.Header.Get("Reply-To"); replyTo != "" { + addresses, err := netmail.ParseAddressList(replyTo) + if err == nil && len(addresses) > 0 { + m.SetReplyTo(addresses[0].Address) + } + } + + if subject := msg.Header.Get("Subject"); subject != "" { + m.SetSubject(subject) + } + + if messageID := msg.Header.Get("Message-ID"); messageID != "" { + m.AddHeader("Message-ID", messageID) + } + + if date := msg.Header.Get("Date"); date != "" { + m.AddHeader("Date", date) + } else { + m.AddHeader("Date", time.Now().Format(time.RFC1123Z)) + } + + contentType := msg.Header.Get("Content-Type") + isHTML := strings.Contains(strings.ToLower(contentType), "text/html") + + if isHTML { + m.SetBody(mail.TextHTML, string(bodyBytes)) + } else { + m.SetBody(mail.TextPlain, string(bodyBytes)) + } + + return m, recipients, nil +} diff --git a/internal/email/sender.go b/internal/email/sender.go new file mode 100644 index 0000000..24e9d74 --- /dev/null +++ b/internal/email/sender.go @@ -0,0 +1,62 @@ +/* +Copyright © 2025 canaria-computer +*/ +package email + +import ( + "fmt" + "strings" + + mail "github.com/xhit/go-simple-mail/v2" +) + +type EmailSender interface { + Send(msg *mail.Email, recipients Recipients) error + Close() error +} + +type Recipients struct { + To []string + Cc []string + Bcc []string +} + +func (r Recipients) IsEmpty() bool { + return len(r.To) == 0 && len(r.Cc) == 0 && len(r.Bcc) == 0 +} + +func (r Recipients) Validate() error { + if r.IsEmpty() { + return fmt.Errorf("at least one recipient is required (To, Cc, or Bcc)") + } + return nil +} + +func AddRecipientsToMessage(msg *mail.Email, recipients Recipients) error { + if err := recipients.Validate(); err != nil { + return err + } + + for _, to := range recipients.To { + to = strings.TrimSpace(to) + if to != "" { + msg.AddTo(to) + } + } + + for _, cc := range recipients.Cc { + cc = strings.TrimSpace(cc) + if cc != "" { + msg.AddCc(cc) + } + } + + for _, bcc := range recipients.Bcc { + bcc = strings.TrimSpace(bcc) + if bcc != "" { + msg.AddBcc(bcc) + } + } + + return nil +} diff --git a/internal/email/smtp.go b/internal/email/smtp.go new file mode 100644 index 0000000..4a71b8a --- /dev/null +++ b/internal/email/smtp.go @@ -0,0 +1,228 @@ +/* +Copyright © 2025 canaria-computer +*/ +package email + +import ( + "crypto/tls" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/canaria-computer/down-force/internal/appconfig" + "github.com/charmbracelet/log" + mail "github.com/xhit/go-simple-mail/v2" +) + +const ( + maxRetryAttempts = 3 + initialRetryDelay = 1 * time.Second + maxRetryDelay = 8 * time.Second +) + +type SMTPSender struct { + client *mail.SMTPClient + server *mail.SMTPServer +} + +func NewSMTPSender(cfg appconfig.SMTPConfig, ignoreTLS bool, timeout int) (*SMTPSender, error) { + if cfg.URI == "" { + return nil, fmt.Errorf("SMTP URI is not configured") + } + + host, port, username, password, err := ParseSMTPURI(cfg.URI) + if err != nil { + return nil, fmt.Errorf("failed to parse SMTP URI: %w", err) + } + + server := mail.NewSMTPClient() + server.Host = host + server.Port = port + server.Username = username + server.Password = password + + if timeout > 0 { + server.ConnectTimeout = time.Duration(timeout) * time.Second + server.SendTimeout = time.Duration(timeout) * time.Second + } else if cfg.Timeout > 0 { + server.ConnectTimeout = time.Duration(cfg.Timeout) * time.Second + server.SendTimeout = time.Duration(cfg.Timeout) * time.Second + } else { + server.ConnectTimeout = 30 * time.Second + server.SendTimeout = 30 * time.Second + } + + authType := cfg.AuthType + if authType == "" || authType == "AUTO" { + server.Authentication = mail.AuthAuto + } else { + switch strings.ToUpper(authType) { + case "PLAIN": + server.Authentication = mail.AuthPlain + case "LOGIN": + server.Authentication = mail.AuthLogin + case "CRAM-MD5": + server.Authentication = mail.AuthCRAMMD5 + default: + return nil, fmt.Errorf("unsupported authentication type: %s (supported: AUTO, PLAIN, LOGIN, CRAM-MD5)", authType) + } + } + + secureMode := strings.ToLower(cfg.Secure) + switch secureMode { + case "ssl", "tls": + server.Encryption = mail.EncryptionSSLTLS + case "starttls": + server.Encryption = mail.EncryptionSTARTTLS + case "none", "": + server.Encryption = mail.EncryptionNone + log.Warn("SMTP connection without encryption (Secure: None) - not recommended for production") + default: + return nil, fmt.Errorf("invalid SMTP secure mode: %s (supported: SSL, STARTTLS, None)", cfg.Secure) + } + + if server.Encryption != mail.EncryptionNone { + server.TLSConfig = &tls.Config{ + ServerName: host, + InsecureSkipVerify: ignoreTLS, + } + } + + server.KeepAlive = false + + return &SMTPSender{ + server: server, + }, nil +} + +func ParseSMTPURI(uri string) (string, int, string, string, error) { + if uri == "" { + return "", 0, "", "", fmt.Errorf("SMTP URI is empty") + } + + parsedURL, err := url.Parse(uri) + if err != nil { + return "", 0, "", "", fmt.Errorf("invalid SMTP URI format: %w", err) + } + + if parsedURL.Scheme != "smtp" && parsedURL.Scheme != "smtps" { + return "", 0, "", "", fmt.Errorf("invalid URI scheme: expected 'smtp://' or 'smtps://', got '%s://'", parsedURL.Scheme) + } + + host := parsedURL.Hostname() + if host == "" { + return "", 0, "", "", fmt.Errorf("host not found in SMTP URI") + } + + portStr := parsedURL.Port() + port := 587 // Default SMTP STARTTLS port + if portStr != "" { + port, err = strconv.Atoi(portStr) + if err != nil { + return "", 0, "", "", fmt.Errorf("invalid port in SMTP URI: %w", err) + } + } + + username := "" + password := "" + if parsedURL.User != nil { + username = parsedURL.User.Username() + password, _ = parsedURL.User.Password() + } + + return host, port, username, password, nil +} + +func (s *SMTPSender) Send(msg *mail.Email, recipients Recipients) error { + if err := recipients.Validate(); err != nil { + return err + } + + if err := AddRecipientsToMessage(msg, recipients); err != nil { + return fmt.Errorf("failed to add recipients to message: %w", err) + } + + var lastErr error + for attempt := 1; attempt <= maxRetryAttempts; attempt++ { + client, err := s.server.Connect() + if err != nil { + lastErr = fmt.Errorf("failed to connect to SMTP server: %w", err) + if shouldRetry(err, attempt) { + log.Warn("SMTP connection failed, retrying...", "attempt", attempt, "error", err) + if attempt < maxRetryAttempts { + backoff := calculateBackoff(attempt) + time.Sleep(backoff) + } + continue + } + return lastErr + } + + // Send the email + err = msg.Send(client) + if err == nil { + log.Info("Email sent successfully") + return nil + } + + lastErr = fmt.Errorf("failed to send email: %w", err) + + if shouldRetry(err, attempt) { + log.Warn("Email send failed, retrying...", "attempt", attempt, "error", err) + if attempt < maxRetryAttempts { + backoff := calculateBackoff(attempt) + time.Sleep(backoff) + } + } else { + return lastErr + } + } + + log.Error("Email send failed after max attempts", "error", lastErr) + return fmt.Errorf("email send failed after %d attempts: %w", maxRetryAttempts, lastErr) +} + +func (s *SMTPSender) Close() error { + return nil +} + +func shouldRetry(err error, attempt int) bool { + if attempt >= maxRetryAttempts { + return false + } + + errStr := strings.ToLower(err.Error()) + + if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline exceeded") { + return true + } + + if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "connection reset") { + return true + } + + if strings.Contains(errStr, "4") && (strings.Contains(errStr, "smtp") || strings.Contains(errStr, "mail")) { + return true + } + + if strings.Contains(errStr, "auth") || strings.Contains(errStr, "authentication") { + return false + } + if strings.Contains(errStr, "5") && (strings.Contains(errStr, "smtp") || strings.Contains(errStr, "mail")) { + return false + } + + return true +} + +func calculateBackoff(attempt int) time.Duration { + multiplier := 1 << (attempt - 1) + backoff := time.Duration(multiplier) * initialRetryDelay + + if backoff > maxRetryDelay { + return maxRetryDelay + } + return backoff +}