// Package report sends a deployment summary email via SMTP. package report import ( "archive/zip" "bytes" "encoding/base64" "fmt" "net/smtp" "net/url" "os" "path/filepath" "strings" "time" ) // SMTP2Go relay configuration for X9.cz deployment reports. const ( smtpHost = "mail-eu.smtp2go.com" smtpPort = "2525" smtpUser = "xetup" smtpPass = "M9ahxHOnJ8fM0CEF" mailFrom = "xetup@x9.cz" mailTo = "net@x9.cz" ) // runsTracker is the Forgejo repo where failed runs are filed as issues. // The email links here (filtered to this machine) so the technician can jump // straight to the run history / bug tracker. const runsTracker = "https://git.xetup.x9.cz/x9/xetup-runs/issues" // localReportPath is where a local HTML copy of the report is always saved. const localReportPath = `C:\X9\report.html` // mimeBoundary separates the HTML body from the zipped-log attachment. Fixed // (not random) because Math/rand is avoided project-wide and a single message // never needs a unique one - the string just must not appear in the content. const mimeBoundary = "xetup-mixed-8f3a2c1d0e7b46f9" // StepResult holds one row of the deployment report. type StepResult struct { Num string Name string Status string // OK, ERROR, SKIPPED, CANCELLED Elapsed time.Duration } // Send builds the deployment report, saves a local HTML copy to C:\X9\, and // emails it via SMTP with retries. The full Deploy.log is attached as a zip // (best-effort - omitted if unreadable). issueURL, when non-empty, is a link // to this run's failure issue in the Forgejo tracker and is shown in the body. // Returns the last SMTP error if all attempts fail (the local copy is always // written regardless). func Send(results []StepResult, issueURL, logPath string) error { hostname, _ := os.Hostname() now := time.Now().Format("2006-01-02 15:04") subject := fmt.Sprintf("xetup report %s", hostname) body := buildHTML(results, hostname, now, issueURL) // Always save local copy so technician has a record even if SMTP fails _ = os.MkdirAll(filepath.Dir(localReportPath), 0755) if err := os.WriteFile(localReportPath, []byte(body), 0644); err != nil { fmt.Fprintf(os.Stderr, "[WARN] Failed to save local report: %v\n", err) } // Zip the log for attachment (best-effort; nil attachment => plain email) attachName, attachData := zipLog(logPath, hostname) // Retry SMTP up to 3 times with exponential backoff (1s, 5s, 15s) delays := []time.Duration{0, 1 * time.Second, 5 * time.Second} var lastErr error for attempt, delay := range delays { if delay > 0 { time.Sleep(delay) } if err := sendMail(subject, body, attachName, attachData); err != nil { lastErr = err fmt.Fprintf(os.Stderr, "[WARN] Email attempt %d/3 failed: %v\n", attempt+1, err) continue } return nil } fmt.Fprintf(os.Stderr, "[ERROR] All email attempts failed. Local copy saved: %s\n", localReportPath) return lastErr } // zipLog reads logPath and returns a zip archive of it (name, bytes). On any // error it returns ("", nil) so the caller sends a plain email without failing. func zipLog(logPath, hostname string) (string, []byte) { data, err := os.ReadFile(logPath) if err != nil { fmt.Fprintf(os.Stderr, "[WARN] Cannot read log for attachment: %v\n", err) return "", nil } var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.Create("Deploy.log") if err == nil { _, err = w.Write(data) } if err == nil { err = zw.Close() } if err != nil { fmt.Fprintf(os.Stderr, "[WARN] Cannot zip log for attachment: %v\n", err) return "", nil } name := hostname + "-Deploy.log.zip" return name, buf.Bytes() } func sendMail(subject, body, attachName string, attach []byte) error { msg := buildMessage(subject, body, attachName, attach) auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost) return smtp.SendMail( smtpHost+":"+smtpPort, auth, mailFrom, []string{mailTo}, msg, ) } // buildMessage assembles the raw RFC 822 message: a plain text/html message // when attach is nil, otherwise multipart/mixed with the zipped log attached. func buildMessage(subject, body, attachName string, attach []byte) []byte { var msg bytes.Buffer fmt.Fprintf(&msg, "From: %s\r\n", mailFrom) fmt.Fprintf(&msg, "To: %s\r\n", mailTo) fmt.Fprintf(&msg, "Subject: %s\r\n", subject) msg.WriteString("MIME-Version: 1.0\r\n") if attach == nil { // Simple single-part HTML message. msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") msg.WriteString(body) } else { // multipart/mixed: HTML body + zipped log attachment. fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mimeBoundary) fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary) msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") msg.WriteString(body) msg.WriteString("\r\n") fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary) msg.WriteString("Content-Type: application/zip\r\n") msg.WriteString("Content-Transfer-Encoding: base64\r\n") fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", attachName) writeBase64(&msg, attach) msg.WriteString("\r\n") fmt.Fprintf(&msg, "--%s--\r\n", mimeBoundary) } return msg.Bytes() } // writeBase64 writes data as base64 wrapped at 76 columns (RFC 2045). func writeBase64(w *bytes.Buffer, data []byte) { enc := base64.StdEncoding.EncodeToString(data) for len(enc) > 76 { w.WriteString(enc[:76]) w.WriteString("\r\n") enc = enc[76:] } w.WriteString(enc) w.WriteString("\r\n") } func buildHTML(results []StepResult, hostname, dateTime, issueURL string) string { var ok, errs, skipped int var rows strings.Builder for _, r := range results { color := "#333" icon := "" switch r.Status { case "OK": ok++ color = "#2e7d32" icon = "✓" case "ERROR": errs++ color = "#c62828" icon = "✗" default: skipped++ color = "#9e9e9e" icon = "–" } elapsed := "" if r.Elapsed > 0 { elapsed = r.Elapsed.Round(time.Second).String() } fmt.Fprintf(&rows, `
Odeslano z xetup.exe — log v priloze (%s-Deploy.log.zip) i lokalne: C:\Windows\Setup\Scripts\Deploy.log
`, hostname, dateTime, rows.String(), summaryColor, summaryText, ok, errs, skipped, trackerLinks(hostname, issueURL), hostname) } // trackerLinks renders the tracker links block: a direct link to this run's // failure issue (when present) plus a link filtered to this machine's runs. func trackerLinks(hostname, issueURL string) string { filter := fmt.Sprintf("%s?q=%s&type=all&state=all", runsTracker, url.QueryEscape(hostname)) var b strings.Builder b.WriteString(``) return b.String() }