|
"""Render digest.json into the cloudwp Mailchimp-style HTML and send via Mailgun. |
|
|
|
Usage: MAILGUN_KEY=<key> python3 mail.py [digest.json] |
|
|
|
SMTP is unusable from the Claude cloud sandbox (its egress proxy rejects raw TCP |
|
on non-443 ports), so this goes over Mailgun's REST API on 443. The template |
|
mirrors .doc/cloudwp/mailchimp/email-template-announcement.html. |
|
""" |
|
import base64 |
|
import html |
|
import json |
|
import os |
|
import re |
|
import sys |
|
import urllib.request |
|
from pathlib import Path |
|
|
|
|
|
DOMAIN = "cloudwp.pro" |
|
TO = "nel@tseng.cc" |
|
COLORS = { |
|
"直接影響你手上的東西": "#ef4444", |
|
"電商": "#f97316", |
|
"廣告投放": "#eab308", |
|
"SEO": "#22c55e", |
|
"技術棧與金流": "#3b82f6", |
|
"AI": "#a855f7", |
|
"遊戲與生成式素材": "#ec4899", |
|
"UI 與設計系統": "#14b8a6", |
|
} |
|
|
|
|
|
def render_item(item): |
|
time = ( |
|
f'<p style="font-size: 13px; color: #64748b; margin: 5px 0 0 0;">{html.escape(item["time"])}</p>' |
|
if item.get("time") else "" |
|
) |
|
return f""" |
|
<div style="margin: 0 0 24px 0; padding: 0 0 24px 0; border-bottom: 1px solid #e2e8f0;"> |
|
<a href="{html.escape(item['url'])}" style="font-size: 17px; font-weight: 600; color: #0f172a; line-height: 1.45; text-decoration: none;">{html.escape(item['title'])}</a> |
|
{time} |
|
<p style="font-size: 15px; line-height: 1.75; color: #1e293b; margin: 10px 0 12px 0;">{html.escape(item['fact'])}</p> |
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #eef2f7; border-radius: 6px;"> |
|
<tr> |
|
<td style="padding: 15px 0 14px 16px; vertical-align: top; width: 34px;"> |
|
<img src="https://raw.githubusercontent.com/neltseng/hz-assets/main/info.png" width="18" height="18" alt="" style="display: block;"> |
|
</td> |
|
<td style="padding: 14px 16px 14px 0; vertical-align: top;"> |
|
<p style="font-size: 15px; line-height: 1.65; color: #1e293b; margin: 0;">{html.escape(item['action'])}</p> |
|
</td> |
|
</tr> |
|
</table> |
|
</div>""" |
|
|
|
|
|
def tag_of(sec): |
|
# Older digests prefixed the title with an emoji; the template renders a colored tag, so strip it. |
|
return re.sub(r"^\W+", "", sec["title"]).strip() |
|
|
|
|
|
def render_section(sec): |
|
tag = tag_of(sec) |
|
color = COLORS.get(tag, "#94a3b8") |
|
items = "".join(render_item(i) for i in sec["items"]) |
|
return f""" |
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 36px 0 18px 0;"> |
|
<tr><td style="background-color: {color}; border-radius: 4px; padding: 5px 12px;"> |
|
<span style="font-size: 14px; color: #ffffff; font-weight: 600; letter-spacing: 0.02em;">{html.escape(tag)}</span> |
|
</td></tr> |
|
</table> |
|
{items}""" |
|
|
|
|
|
def filtered_title(f): |
|
title = html.escape(f["title"]) |
|
if not f.get("url"): |
|
return title |
|
return ( |
|
f'<a href="{html.escape(f["url"])}" style="color: #334155; text-decoration: none;">{title}</a>' |
|
) |
|
|
|
|
|
def render_filtered(filtered): |
|
if not filtered: |
|
return "" |
|
rows = "".join( |
|
f'<li style="font-size: 15px; line-height: 1.6; color: #334155; margin-bottom: 12px;">' |
|
f'{filtered_title(f)}' |
|
f'<span style="display: block; font-size: 14px; color: #64748b; margin-top: 2px;">' |
|
f'{html.escape(f.get("zh", ""))}' |
|
f'{" " if f.get("zh") else ""}<span style="color: #94a3b8;">{html.escape(f["src"])}</span></span></li>' |
|
for f in filtered |
|
) |
|
return f""" |
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 40px 0 0 0; border-top: 1px solid #e2e8f0;"> |
|
<tr><td style="padding: 24px 0 0 0;"> |
|
<p style="font-size: 16px; font-weight: 600; color: #0f172a; margin: 0 0 14px 0;">已過濾({len(filtered)} 則)</p> |
|
<ul style="margin: 0; padding-left: 20px;">{rows}</ul> |
|
</td></tr> |
|
</table>""" |
|
|
|
|
|
def render(d): |
|
s = d["stats"] |
|
sections = "".join(render_section(x) for x in d["sections"]) |
|
note = ( |
|
f'<p style="font-size: 14px; line-height: 1.7; color: #334155; margin: 28px 0 0 0; ' |
|
f'padding: 14px 16px; background-color: #fffbeb; border-left: 3px solid #eab308;">{html.escape(d["note"])}</p>' |
|
if d.get("note") else "" |
|
) |
|
return f"""<!DOCTYPE html> |
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-Hant"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>黑閃日報 {d['date']}</title> |
|
<style type="text/css"> |
|
body, table, td {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }} |
|
body {{ margin: 0; padding: 0; background-color: #f4f5f7; -webkit-text-size-adjust: 100%; }} |
|
table {{ border-collapse: collapse; }} |
|
img {{ border: 0; outline: none; text-decoration: none; }} |
|
a {{ color: #2962FF; text-decoration: none; }} |
|
a:hover {{ text-decoration: underline; }} |
|
@media only screen and (max-width: 600px) {{ |
|
.container {{ width: 100% !important; }} |
|
.content {{ padding: 24px 20px !important; }} |
|
.header {{ padding: 20px !important; }} |
|
.footer-content {{ padding: 20px !important; }} |
|
}} |
|
</style> |
|
</head> |
|
<body> |
|
<div style="display:none;font-size:1px;color:#f4f5f7;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;"> |
|
抓 {s['fetched']} 則,保留 {s['kept']} 則 |
|
</div> |
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7;"> |
|
<tr> |
|
<td align="center" style="padding: 32px 16px;"> |
|
|
|
<table role="presentation" class="container" width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff;"> |
|
|
|
<tr> |
|
<td class="header" style="padding: 28px 35px;"> |
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"> |
|
<tr> |
|
<td style="text-align: left;"> |
|
<img src="https://cloudwp.pro/assets/images/logo-dark.svg" alt="cloudwp" width="120" style="display: inline-block; vertical-align: middle;"> |
|
</td> |
|
<td style="text-align: right;"> |
|
<span style="font-size: 13px; color: #94a3b8;">{d['date']}</span> |
|
</td> |
|
</tr> |
|
</table> |
|
</td> |
|
</tr> |
|
|
|
<tr> |
|
<td class="content" style="padding: 0 35px 40px;"> |
|
|
|
<h1 style="font-size: 24px; line-height: 1.4; color: #0f172a; margin: 0 0 6px 0; font-weight: 700;">黑閃日報</h1> |
|
<p style="font-size: 13px; color: #64748b; margin: 0;">抓 {s['fetched']} 則 保留 {s['kept']} 則 過濾 {s['filtered']} 則</p> |
|
|
|
{sections} |
|
{render_filtered(d.get('filtered', []))} |
|
{note} |
|
|
|
</td> |
|
</tr> |
|
|
|
<tr> |
|
<td style="border-top: 1px solid #e2e8f0; background-color: #f8fafc;"> |
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"> |
|
<tr> |
|
<td class="footer-content" style="padding: 28px 35px; text-align: center;"> |
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 auto;"> |
|
<tr> |
|
<td style="vertical-align: middle; padding-right: 12px;"> |
|
<a href="https://woocommerce.com/development-services/cloudwp/250740886/"> |
|
<img src="https://cloudwp.pro/assets/images/badge/woo-partner-badge-light.png" alt="WooCommerce Agency Partner" height="28" style="display: block;"> |
|
</a> |
|
</td> |
|
<td style="vertical-align: middle; padding-right: 12px;"> |
|
<a href="https://www.facebook.com/CloudWP"> |
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#94a3b8"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> |
|
</a> |
|
</td> |
|
<td style="vertical-align: middle;"> |
|
<a href="https://github.com/orgs/LazyProApp/repositories"> |
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> |
|
</a> |
|
</td> |
|
</tr> |
|
</table> |
|
<p style="font-size: 13px; line-height: 1.8; color: #94a3b8; margin: 16px 0 0 0;"> |
|
cloudwp © 2026 黑閃實驗室 | 統編 93067949<br> |
|
台灣 WooCommerce 商家的技術夥伴 |
|
</p> |
|
</td> |
|
</tr> |
|
</table> |
|
</td> |
|
</tr> |
|
|
|
</table> |
|
</td> |
|
</tr> |
|
</table> |
|
</body> |
|
</html>""" |
|
|
|
|
|
def to_text(d): |
|
lines = [f"黑閃日報 {d['date']}", ""] |
|
for sec in d["sections"]: |
|
lines.append(tag_of(sec)) |
|
for i in sec["items"]: |
|
lines += ["", i["title"], i.get("time", ""), i["fact"], f"→ {i['action']}", i["url"]] |
|
lines.append("") |
|
if d.get("filtered"): |
|
lines.append(f"已過濾({len(d['filtered'])} 則)") |
|
lines += [ |
|
f"{f['title']}\n {f.get('zh', '')}({f['src']})" + (f"\n {f['url']}" if f.get("url") else "") |
|
for f in d["filtered"] |
|
] |
|
return "\n".join(lines) |
|
|
|
|
|
def norm_title(t): |
|
"""標題常帶 HTML entity,或被摘要層加上「(昨天已報過)」之類的尾註,比對前先剝掉。""" |
|
return re.sub(r"([^()]*)\s*$", "", html.unescape(t)).strip().lower() |
|
|
|
|
|
def backfill_urls(d, here): |
|
"""filtered 漏填 url 時,用 latest.json 裡同標題的那則補回來。""" |
|
path = here / "latest.json" |
|
if not path.exists(): |
|
return |
|
by_title = { |
|
norm_title(i["title"]): i["url"] |
|
for i in json.loads(path.read_text(encoding="utf-8")) |
|
if i.get("url") |
|
} |
|
for f in d.get("filtered", []): |
|
if not f.get("url"): |
|
f["url"] = by_title.get(norm_title(f["title"]), "") |
|
|
|
|
|
def multipart(fields, inline): |
|
"""Build a multipart/form-data body. `inline` is a list of (filename, bytes) sent as cid: images.""" |
|
boundary = "----hz" + base64.urlsafe_b64encode(os.urandom(12)).decode().strip("=") |
|
out = bytearray() |
|
for name, value in fields.items(): |
|
out += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode() |
|
out += value.encode() + b"\r\n" |
|
for filename, blob in inline: |
|
out += f'--{boundary}\r\nContent-Disposition: form-data; name="inline"; filename="{filename}"\r\n'.encode() |
|
out += b"Content-Type: image/png\r\n\r\n" + blob + b"\r\n" |
|
out += f"--{boundary}--\r\n".encode() |
|
return bytes(out), f"multipart/form-data; boundary={boundary}" |
|
|
|
|
|
def main(): |
|
key = os.environ.get("MAILGUN_KEY") |
|
if not key: |
|
sys.exit("MAILGUN_KEY 未設定") |
|
here = Path(__file__).parent |
|
path = sys.argv[1] if len(sys.argv) > 1 else str(here / "digest.json") |
|
d = json.loads(Path(path).read_text(encoding="utf-8")) |
|
backfill_urls(d, here) |
|
|
|
icon = here / "sparkles.png" |
|
body, content_type = multipart( |
|
{ |
|
"from": f"黑閃日報 <noreply@{DOMAIN}>", |
|
"to": TO, |
|
"subject": f"黑閃日報 {d['date']}", |
|
"text": to_text(d), |
|
"html": render(d), |
|
}, |
|
[("sparkles.png", icon.read_bytes())] if icon.exists() else [], |
|
) |
|
|
|
req = urllib.request.Request(f"https://api.mailgun.net/v3/{DOMAIN}/messages", data=body) |
|
req.add_header("Content-Type", content_type) |
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"api:{key}".encode()).decode()) |
|
with urllib.request.urlopen(req, timeout=30) as resp: |
|
print(resp.read().decode()) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |