diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-01 22:07:33 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-01 22:07:33 +0900 |
| commit | 694694e67936ccabb0f51edad7023c965850e310 (patch) | |
| tree | cd8635e524aa1afb68459be3423d920c28fc2bf7 | |
| parent | 3a961122bdf64cf72cf1b3770e9d71865a3aac52 (diff) | |
Add daily recap email job via Postfix MTA container
Sends an HTML digest of the next 7 days' events to
efemeraladdress+golive@gmail.com at 08:00 JST daily (23:00 UTC cron).
Adds scraper to the web Docker network so it can reach mail:25.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | Dockerfile | 5 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | scripts/send-recap.ts | 278 |
3 files changed, 284 insertions, 1 deletions
@@ -37,7 +37,10 @@ COPY scripts ./scripts COPY app ./app COPY tsconfig.json ./ RUN apt-get update && apt-get install -y cron && rm -rf /var/lib/apt/lists/* -RUN echo '0 */6 * * * root cd /app && DB_PATH=/app/data/events.db node --import tsx/esm scripts/scrape.ts >> /app/data/scraper.log 2>&1' > /etc/cron.d/scraper \ +RUN printf '%s\n%s\n' \ + '0 */6 * * * root cd /app && DB_PATH=/app/data/events.db node --import tsx/esm scripts/scrape.ts >> /app/data/scraper.log 2>&1' \ + '0 23 * * * root cd /app && DB_PATH=/app/data/events.db node --import tsx/esm scripts/send-recap.ts >> /app/data/recap.log 2>&1' \ + > /etc/cron.d/scraper \ && chmod 0644 /etc/cron.d/scraper RUN mkdir -p /app/data COPY scripts/scraper-entrypoint.sh ./scripts/scraper-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index d9412c5..f2702e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,8 @@ services: depends_on: - app restart: unless-stopped + networks: + - web networks: web: diff --git a/scripts/send-recap.ts b/scripts/send-recap.ts new file mode 100644 index 0000000..5a11d0a --- /dev/null +++ b/scripts/send-recap.ts @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Daily live event recap mailer. + * Queries upcoming events (tomorrow + 6 days) from SQLite and sends an HTML + * digest via the internal Postfix container (mail:25 on the web Docker network). + * + * Env vars: + * DB_PATH path to SQLite database (default: /app/data/events.db) + * MAIL_HOST SMTP relay hostname (default: mail) + * MAIL_PORT SMTP port (default: 25) + * RECAP_TO recipient address (default: efemeraladdress+golive@gmail.com) + */ + +import Database from "better-sqlite3"; +import { createConnection } from "net"; + +const DB_PATH = process.env.DB_PATH ?? "/app/data/events.db"; +const MAIL_HOST = process.env.MAIL_HOST ?? "mail"; +const MAIL_PORT = parseInt(process.env.MAIL_PORT ?? "25", 10); +const FROM = "noreply@golive.yyamashita.com"; +const TO = process.env.RECAP_TO ?? "efemeraladdress+golive@gmail.com"; +const DAYS = 7; + +// ---- date helpers (JST = UTC+9) ---------------------------------------- + +const JST_MS = 9 * 60 * 60 * 1000; + +function nowJST(): Date { + return new Date(Date.now() + JST_MS); +} + +function isoDate(d: Date): string { + // Returns YYYY-MM-DD in JST + return new Date(d.getTime() + JST_MS).toISOString().slice(0, 10); +} + +function addDays(d: Date, n: number): Date { + const r = new Date(d); + r.setUTCDate(r.getUTCDate() + n); + return r; +} + +function fmtJST(dateStr: string): string { + const DOW = ["日", "月", "火", "水", "木", "金", "土"]; + // Parse as JST midnight + const d = new Date(`${dateStr}T00:00:00+09:00`); + const [y, m, day] = dateStr.split("-"); + return `${y}/${m}/${day}(${DOW[d.getDay()]})`; +} + +// ---- database ---------------------------------------------------------- + +interface EventRow { + id: number; + venue_name: string; + title: string; + artist: string | null; + date: string; + start_time: string | null; + price: string | null; + ticket_url: string | null; + source_url: string | null; +} + +function fetchEvents(from: string, to: string): EventRow[] { + const db = new Database(DB_PATH, { readonly: true }); + try { + return db + .prepare( + `SELECT e.id, v.name AS venue_name, e.title, e.artist, + e.date, e.start_time, e.price, e.ticket_url, e.source_url + FROM events e JOIN venues v ON e.venue_id = v.id + WHERE e.date >= ? AND e.date <= ? + ORDER BY e.date ASC, e.start_time ASC` + ) + .all(from, to) as EventRow[]; + } finally { + db.close(); + } +} + +// ---- HTML builder ------------------------------------------------------- + +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function buildHtml(events: EventRow[], from: string, to: string): string { + const head = `<!DOCTYPE html> +<html lang="ja"> +<head> +<meta charset="UTF-8"> +<style> +body{font-family:sans-serif;font-size:14px;color:#333;max-width:800px;margin:0 auto;padding:16px} +h1{font-size:20px;border-bottom:3px solid #c00;padding-bottom:6px;color:#c00} +h2{font-size:15px;color:#555;margin:20px 0 6px;background:#f5f5f5;padding:4px 8px;border-left:4px solid #c00} +table{border-collapse:collapse;width:100%;margin-bottom:4px} +th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;vertical-align:top} +th{background:#fafafa;font-weight:bold;font-size:12px;color:#666} +.artist{font-weight:bold} +.sub{color:#777;font-size:12px} +a{color:#1a7a3c;text-decoration:none} +a:hover{text-decoration:underline} +.none{color:#aaa;font-style:italic;font-size:12px} +</style> +</head> +<body> +<h1>東京ライブハウス 週間イベント</h1> +<p style="color:#777;font-size:13px">${esc(fmtJST(from))} 〜 ${esc(fmtJST(to))} のイベント一覧</p> +`; + + if (events.length === 0) { + return ( + head + + `<p>この期間のイベントはまだ登録されていません。</p></body></html>` + ); + } + + const byDate = new Map<string, EventRow[]>(); + for (const e of events) { + const list = byDate.get(e.date) ?? []; + list.push(e); + byDate.set(e.date, list); + } + + let body = ""; + for (const [date, rows] of byDate) { + body += `<h2>${esc(fmtJST(date))} <span style="font-weight:normal;font-size:13px">${rows.length} 件</span></h2>\n`; + body += `<table>\n<tr><th>会場</th><th>アーティスト / タイトル</th><th>開演</th><th>料金</th><th>リンク</th></tr>\n`; + for (const e of rows) { + const nameCell = e.artist + ? `<span class="artist">${esc(e.artist)}</span><br><span class="sub">${esc(e.title)}</span>` + : `<span class="sub">${esc(e.title)}</span>`; + const time = e.start_time ? esc(e.start_time) : `<span class="none">-</span>`; + const price = e.price ? esc(e.price) : `<span class="none">-</span>`; + const url = e.ticket_url ?? e.source_url; + const link = url + ? `<a href="${esc(url)}">詳細</a>` + : `<span class="none">-</span>`; + body += `<tr><td>${esc(e.venue_name)}</td><td>${nameCell}</td><td>${time}</td><td>${price}</td><td>${link}</td></tr>\n`; + } + body += `</table>\n`; + } + + return head + body + `</body></html>`; +} + +// ---- RFC 2047 subject / MIME helpers ------------------------------------ + +function encodeSubject(str: string): string { + return `=?UTF-8?B?${Buffer.from(str).toString("base64")}?=`; +} + +function foldBase64(b64: string): string { + // Chunk base64 into 76-char lines per MIME spec + return b64.match(/.{1,76}/g)?.join("\r\n") ?? b64; +} + +function buildMessage(subject: string, html: string): string { + const date = new Date().toUTCString(); + const b64body = foldBase64(Buffer.from(html).toString("base64")); + return [ + `Date: ${date}`, + `From: Tokyo Livehouse Events <${FROM}>`, + `To: ${TO}`, + `Subject: ${encodeSubject(subject)}`, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + b64body, + ].join("\r\n"); +} + +// ---- raw SMTP client ---------------------------------------------------- + +function smtpSend( + host: string, + port: number, + from: string, + to: string, + message: string +): Promise<void> { + return new Promise((resolve, reject) => { + const socket = createConnection(port, host); + let buf = ""; + + // Phases: 0=greeting 1=ehlo 2=mail-from 3=rcpt-to 4=data 5=body+dot 6=quit 7=done + let phase = 0; + + function advance() { + phase++; + switch (phase) { + case 1: + socket.write("EHLO recap\r\n"); + break; + case 2: + socket.write(`MAIL FROM:<${from}>\r\n`); + break; + case 3: + socket.write(`RCPT TO:<${to}>\r\n`); + break; + case 4: + socket.write("DATA\r\n"); + break; + case 5: + // Dot-stuffing: lines beginning with "." get an extra "." + const stuffed = message + .split("\r\n") + .map((l) => (l.startsWith(".") ? "." + l : l)) + .join("\r\n"); + socket.write(stuffed + "\r\n.\r\n"); + break; + case 6: + socket.write("QUIT\r\n"); + break; + case 7: + socket.end(); + resolve(); + break; + } + } + + socket.on("data", (chunk) => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf("\r\n")) !== -1) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 2); + if (!line) continue; + + const code = parseInt(line.slice(0, 3), 10); + const isFinal = line[3] !== "-"; // "-" = continuation line + + if (isFinal) { + if (code >= 400) { + const err = new Error(`SMTP ${code}: ${line}`); + socket.destroy(); + reject(err); + return; + } + advance(); + } + } + }); + + socket.on("error", reject); + }); +} + +// ---- main --------------------------------------------------------------- + +async function main() { + const today = nowJST(); + const from = isoDate(addDays(today, 1)); // tomorrow + const to = isoDate(addDays(today, DAYS)); // 7 days out + + console.log(`[recap] querying events ${from} – ${to}`); + const events = fetchEvents(from, to); + console.log(`[recap] ${events.length} events found`); + + const subject = `東京ライブハウス ${fmtJST(from).slice(5, 10)} 〜 ${fmtJST(to).slice(5, 10)} のイベント(${events.length} 件)`; + const html = buildHtml(events, from, to); + const message = buildMessage(subject, html); + + console.log(`[recap] sending to ${TO} via ${MAIL_HOST}:${MAIL_PORT}`); + await smtpSend(MAIL_HOST, MAIL_PORT, FROM, TO, message); + console.log("[recap] sent successfully"); +} + +main().catch((err) => { + console.error("[recap] error:", err); + process.exit(1); +}); |
