#!/usr/bin/env node /** * Daily live event recap mailer. * Queries today's events (JST) from SQLite and sends an HTML digest via the * mail container (Postfix + OpenDKIM) which delivers directly to recipient MX. * * Env vars: * DB_PATH path to SQLite database (default: /app/data/events.db) * MAIL_HOST SMTP host (default: mail — the Postfix container) * MAIL_PORT SMTP port (default: 25) * RECAP_TO recipient address (default: mazideyabai@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 EHLO_DOMAIN = "golive.yyamashita.com"; const TO = process.env.RECAP_TO ?? "mazideyabai@gmail.com"; const APP_URL = "https://golive.yyamashita.com"; // ---- 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 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[], date: string): string { const pageUrl = `${APP_URL}/events/by-date?date=${date}`; const head = `

東京ライブハウス ${esc(fmtJST(date))} のライブ

${esc(pageUrl)}

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

本日のイベントはまだ登録されていません。

`; } let body = `\n\n`; for (const e of events) { 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`; body += `\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 msgId = `<${Date.now()}.recap@${EHLO_DOMAIN}>`; const b64body = foldBase64(Buffer.from(html).toString("base64")); return [ `Date: ${date}`, `Message-ID: ${msgId}`, `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 ${EHLO_DOMAIN}\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 date = isoDate(new Date()); console.log(`[recap] querying events for ${date}`); const events = fetchEvents(date, date); console.log(`[recap] ${events.length} events found`); const subject = `東京ライブハウス ${fmtJST(date)} のライブ(${events.length} 件)`; const html = buildHtml(events, date); 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); });