From 694694e67936ccabb0f51edad7023c965850e310 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Mon, 1 Jun 2026 22:07:33 +0900 Subject: 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 --- Dockerfile | 5 +- docker-compose.yml | 2 + scripts/send-recap.ts | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 scripts/send-recap.ts diff --git a/Dockerfile b/Dockerfile index 0bc37da..9eab66d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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, """); +} + +function buildHtml(events: EventRow[], from: string, to: string): string { + const head = ` + + + + + + +

東京ライブハウス 週間イベント

+

${esc(fmtJST(from))} 〜 ${esc(fmtJST(to))} のイベント一覧

+`; + + if (events.length === 0) { + return ( + head + + `

この期間のイベントはまだ登録されていません。

` + ); + } + + const byDate = new Map(); + 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 += `

${esc(fmtJST(date))} ${rows.length} 件

\n`; + body += `\n\n`; + for (const e of rows) { + const nameCell = e.artist + ? `${esc(e.artist)}
${esc(e.title)}` + : `${esc(e.title)}`; + const time = e.start_time ? esc(e.start_time) : `-`; + const price = e.price ? esc(e.price) : `-`; + const url = e.ticket_url ?? e.source_url; + const link = url + ? `詳細` + : `-`; + body += `\n`; + } + body += `
会場アーティスト / タイトル開演料金リンク
${esc(e.venue_name)}${nameCell}${time}${price}${link}
\n`; + } + + return head + body + ``; +} + +// ---- 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 { + 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); +}); -- cgit v1.2.3