All checks were successful
release / build-and-release (push) Successful in 35s
buildHTML ran before zipLog, so the email footer always claimed "log v priloze (<host>-Deploy.log.zip)" even when the log was unreadable and no attachment was added. Zip the log first and pass the attachment name into buildHTML; footer now names the zip only when present, otherwise points solely to the local copy. Covered by TestBuildHTMLFooterMatchesAttachment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
// 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)
|
|
|
|
// Zip the log first (best-effort; nil attachment => plain email) so the
|
|
// body's footer can state accurately whether the log is actually attached.
|
|
attachName, attachData := zipLog(logPath, hostname)
|
|
body := buildHTML(results, hostname, now, issueURL, attachName)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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, attachName 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,
|
|
`<tr><td style="padding:4px 8px;color:%s;font-size:16px;text-align:center">%s</td>`+
|
|
`<td style="padding:4px 8px;color:#666">%s</td>`+
|
|
`<td style="padding:4px 8px">%s</td>`+
|
|
`<td style="padding:4px 8px;color:%s;font-weight:bold">%s</td>`+
|
|
`<td style="padding:4px 8px;color:#999;text-align:right">%s</td></tr>`,
|
|
color, icon, r.Num, r.Name, color, r.Status, elapsed)
|
|
}
|
|
|
|
summaryColor := "#2e7d32"
|
|
summaryText := "Deployment OK"
|
|
if errs > 0 {
|
|
summaryColor = "#c62828"
|
|
summaryText = fmt.Sprintf("Deployment finished with %d error(s)", errs)
|
|
}
|
|
|
|
return fmt.Sprintf(`<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"></head>
|
|
<body style="font-family:Segoe UI,Arial,sans-serif;margin:0;padding:20px;background:#f5f5f5">
|
|
<div style="max-width:640px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1)">
|
|
<div style="background:#223B47;padding:20px 24px;color:#fff">
|
|
<h1 style="margin:0;font-size:20px">xetup report</h1>
|
|
<p style="margin:6px 0 0;opacity:0.8">%s — %s</p>
|
|
</div>
|
|
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
|
|
<tr style="background:#f9f9f9">
|
|
<th style="padding:6px 8px;text-align:center;width:30px"></th>
|
|
<th style="padding:6px 8px;text-align:left;width:40px">Krok</th>
|
|
<th style="padding:6px 8px;text-align:left">Nazev</th>
|
|
<th style="padding:6px 8px;text-align:left;width:70px">Status</th>
|
|
<th style="padding:6px 8px;text-align:right;width:60px">Cas</th>
|
|
</tr>
|
|
%s
|
|
</table>
|
|
<div style="padding:16px 24px;background:%s;color:#fff;text-align:center;font-weight:bold">
|
|
%s — OK: %d CHYBY: %d PRESKOCENO: %d
|
|
</div>
|
|
%s
|
|
</div>
|
|
<p style="text-align:center;color:#999;font-size:12px;margin-top:16px">
|
|
%s
|
|
</p>
|
|
</body></html>`,
|
|
hostname, dateTime,
|
|
rows.String(),
|
|
summaryColor, summaryText, ok, errs, skipped,
|
|
trackerLinks(hostname, issueURL),
|
|
logFooter(attachName))
|
|
}
|
|
|
|
// logFooter names the zipped log in the footer only when it is actually
|
|
// attached; otherwise it points solely to the local copy on the machine.
|
|
func logFooter(attachName string) string {
|
|
if attachName != "" {
|
|
return fmt.Sprintf("Odeslano z xetup.exe — log v priloze (%s) i lokalne: "+
|
|
`C:\Windows\Setup\Scripts\Deploy.log`, attachName)
|
|
}
|
|
return "Odeslano z xetup.exe — log lokalne: " +
|
|
`C:\Windows\Setup\Scripts\Deploy.log`
|
|
}
|
|
|
|
// 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(`<div style="padding:12px 24px;font-size:13px;color:#555;border-top:1px solid #eee">`)
|
|
if issueURL != "" {
|
|
fmt.Fprintf(&b,
|
|
`<p style="margin:0 0 6px"><b>Chyby tohoto behu:</b> `+
|
|
`<a href="%s" style="color:#c62828">%s</a></p>`,
|
|
issueURL, issueURL)
|
|
}
|
|
fmt.Fprintf(&b,
|
|
`<p style="margin:0">Vsechny behy tohoto stroje v trackeru: `+
|
|
`<a href="%s" style="color:#223B47">%s</a></p>`,
|
|
filter, filter)
|
|
b.WriteString(`</div>`)
|
|
return b.String()
|
|
}
|