summaryrefslogtreecommitdiff
path: root/scripts/send-recap.ts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/send-recap.ts')
-rw-r--r--scripts/send-recap.ts278
1 files changed, 278 insertions, 0 deletions
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, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;");
+}
+
+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);
+});