send_summary_email.rb 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. # tools/send_summary_email.rb
  2. #
  3. # Sends an HTML summary email via SMTP when scraper errors are present.
  4. # Called by run_all.sh; reads summary data from stdin in the format:
  5. #
  6. # Line 1: finish timestamp (UTC)
  7. # Line 2: total scraper count
  8. # Line 3: total saved
  9. # Line 4: total warns
  10. # Lines 5+: pipe-delimited summary rows — name|saved|warns|status
  11. #
  12. # Required env vars (set via docker-compose.yml from .env):
  13. # SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD,
  14. # SMTP_SMTPSecure, SMTP_SENTFROM, SMTP_ADDADDRESS
  15. require "net/smtp"
  16. require "openssl"
  17. # ── Read SMTP config ────────────────────────────────────────────────────────
  18. host = ENV.fetch("SMTP_HOST", "")
  19. port = ENV.fetch("SMTP_PORT", "587").to_i
  20. username = ENV.fetch("SMTP_USERNAME", "")
  21. password = ENV.fetch("SMTP_PASSWORD", "")
  22. secure = ENV.fetch("SMTP_SMTPSecure", "tls").downcase # "tls" or "ssl"
  23. from = ENV.fetch("SMTP_SENTFROM", "")
  24. to = ENV.fetch("SMTP_ADDADDRESS", "")
  25. if host.empty? || from.empty? || to.empty?
  26. warn "[send_summary_email] SMTP not configured — skipping email"
  27. exit 0
  28. end
  29. # ── Read stdin ───────────────────────────────────────────────────────────────
  30. lines = $stdin.read.split("\n")
  31. finish_time = lines[0].to_s.strip
  32. total_count = lines[1].to_s.strip
  33. total_saved = lines[2].to_s.strip
  34. total_warns = lines[3].to_s.strip
  35. entries = lines[4..] || []
  36. rows = entries.map do |e|
  37. parts = e.split("|")
  38. { name: parts[0].to_s, saved: parts[1].to_s, warns: parts[2].to_s, status: parts[3].to_s.strip }
  39. end
  40. error_rows = rows.select { |r| r[:status] == "ERROR" }
  41. blocked_rows = rows.select { |r| r[:status] == "blocked" }
  42. warn_rows = rows.select { |r| r[:status] == "warn" }
  43. # ── Build subject ────────────────────────────────────────────────────────────
  44. error_count = error_rows.size
  45. subject = if error_count > 0
  46. "TAS Councils Scraper — #{error_count} error(s) — #{finish_time} UTC"
  47. else
  48. "TAS Councils Scraper — completed with warnings — #{finish_time} UTC"
  49. end
  50. # ── Build HTML body ──────────────────────────────────────────────────────────
  51. STATUS_COLOUR = {
  52. "ok" => "#198754",
  53. "warn" => "#856404",
  54. "blocked" => "#856404",
  55. "ERROR" => "#dc3545"
  56. }.freeze
  57. STATUS_BG = {
  58. "ok" => "#d1e7dd",
  59. "warn" => "#fff3cd",
  60. "blocked" => "#fff3cd",
  61. "ERROR" => "#f8d7da"
  62. }.freeze
  63. row_html = rows.map do |r|
  64. colour = STATUS_COLOUR.fetch(r[:status], "#333")
  65. bg = STATUS_BG.fetch(r[:status], "#fff")
  66. <<~TR
  67. <tr style="background:#{bg}">
  68. <td style="padding:4px 10px;font-family:monospace">#{r[:name]}</td>
  69. <td style="padding:4px 10px;text-align:right">#{r[:saved]}</td>
  70. <td style="padding:4px 10px;text-align:right">#{r[:warns]}</td>
  71. <td style="padding:4px 10px;font-weight:bold;color:#{colour}">#{r[:status]}</td>
  72. </tr>
  73. TR
  74. end.join
  75. html_body = <<~HTML
  76. <html><body style="font-family:sans-serif;color:#333;max-width:700px">
  77. <h2 style="margin-bottom:4px">TAS Councils Scraper Summary</h2>
  78. <p style="color:#666;margin-top:0">Finished #{finish_time} UTC</p>
  79. #{error_rows.any? ? "<p style='color:#dc3545;font-weight:bold'>&#9888; #{error_rows.size} scraper(s) exited with errors: #{error_rows.map { |r| r[:name] }.join(', ')}</p>" : ""}
  80. #{blocked_rows.any? ? "<p style='color:#856404'>&#9888; #{blocked_rows.size} scraper(s) blocked by WAF/Cloudflare: #{blocked_rows.map { |r| r[:name] }.join(', ')}</p>" : ""}
  81. <table cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;margin-top:12px">
  82. <thead>
  83. <tr style="background:#343a40;color:#fff">
  84. <th style="padding:6px 10px;text-align:left">Council</th>
  85. <th style="padding:6px 10px;text-align:right">Saved</th>
  86. <th style="padding:6px 10px;text-align:right">Warns</th>
  87. <th style="padding:6px 10px;text-align:left">Status</th>
  88. </tr>
  89. </thead>
  90. <tbody>
  91. #{row_html}
  92. </tbody>
  93. <tfoot>
  94. <tr style="background:#f8f9fa;font-weight:bold">
  95. <td style="padding:6px 10px">TOTAL (#{total_count} scrapers)</td>
  96. <td style="padding:6px 10px;text-align:right">#{total_saved}</td>
  97. <td style="padding:6px 10px;text-align:right">#{total_warns}</td>
  98. <td></td>
  99. </tr>
  100. </tfoot>
  101. </table>
  102. </body></html>
  103. HTML
  104. # ── Compose RFC2822 message ──────────────────────────────────────────────────
  105. boundary = "boundary_#{Time.now.to_i}"
  106. message = <<~MSG
  107. From: TAS Scraper <#{from}>
  108. To: #{to}
  109. Subject: #{subject}
  110. MIME-Version: 1.0
  111. Content-Type: multipart/alternative; boundary="#{boundary}"
  112. --#{boundary}
  113. Content-Type: text/plain; charset=UTF-8
  114. TAS Councils Scraper Summary — #{finish_time} UTC
  115. #{error_rows.any? ? "ERRORS (#{error_rows.size}): #{error_rows.map { |r| r[:name] }.join(', ')}\n" : ""}#{blocked_rows.any? ? "BLOCKED (#{blocked_rows.size}): #{blocked_rows.map { |r| r[:name] }.join(', ')}\n" : ""}
  116. Council Saved Warns Status
  117. #{rows.map { |r| "%-34s %5s %5s %s" % [r[:name], r[:saved], r[:warns], r[:status]] }.join("\n ")}
  118. TOTAL (#{total_count} scrapers) #{total_saved} saved, #{total_warns} warns
  119. --#{boundary}
  120. Content-Type: text/html; charset=UTF-8
  121. #{html_body}
  122. --#{boundary}--
  123. MSG
  124. # ── Send ─────────────────────────────────────────────────────────────────────
  125. begin
  126. smtp = Net::SMTP.new(host, port)
  127. if secure == "ssl"
  128. smtp.enable_tls(OpenSSL::SSL::SSLContext.new)
  129. else
  130. # "tls" → STARTTLS
  131. smtp.enable_starttls(OpenSSL::SSL::SSLContext.new)
  132. end
  133. smtp.start("localhost", username, password, :login) do |s|
  134. s.send_message(message, from, Array(to))
  135. end
  136. warn "[send_summary_email] Email sent to #{to}"
  137. rescue StandardError => e
  138. warn "[send_summary_email] Failed to send email: #{e.class} #{e.message}"
  139. exit 1
  140. end