diff --git a/README.md b/README.md index 35fbf1f..337e11f 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,12 @@ http://localhost:40101 | `collection_path` | `/home/zenn/Collection` | Collection 폴더 경로 | | `recent_traces_limit` | `5` | 최근 흔적 저장 조회 개수 | | `reset_token` | `""` | 초기화 API 보호용 토큰. 비어 있으면 토큰 없이 초기화 가능합니다. | +| `public_base_url` | `https://collection.sori.studio` | 메일 확인 링크에 사용할 공개 URL | +| `mail_enabled` | `false` | Gmail 알림 메일 사용 여부 | +| `mail_smtp_user` | `""` | Gmail SMTP 계정 | +| `mail_smtp_pass` | `""` | Gmail 앱 비밀번호 | +| `mail_to` | `zenn.message@gmail.com` | 알림 수신 주소 | +| `mail_confirm_token` | `""` | 메일 확인 링크 보호 토큰 | 환경 변수로도 덮어쓸 수 있습니다. @@ -265,6 +271,12 @@ http://localhost:40101 - `PORT` - `THRESHOLD_SECONDS` - `RESET_TOKEN` +- `PUBLIC_BASE_URL` +- `MAIL_ENABLED` +- `MAIL_SMTP_USER` +- `MAIL_SMTP_PASS` +- `MAIL_TO` +- `MAIL_CONFIRM_TOKEN` ## 화면 문구 커스터마이징 @@ -333,6 +345,25 @@ EOF docker compose up -d --build ``` +## Gmail 알림 메일 + +마지막 확인 이후 60일, 75일, 89일이 지나면 Gmail SMTP로 알림 메일을 보낼 수 있습니다. + +`.env` 예시: + +```bash +PUBLIC_BASE_URL=https://collection.sori.studio +MAIL_ENABLED=true +MAIL_SMTP_USER=zenn.message@gmail.com +MAIL_SMTP_PASS=구글-앱-비밀번호 +MAIL_TO=zenn.message@gmail.com +MAIL_CONFIRM_TOKEN=긴-랜덤-확인-토큰 +``` + +메일의 `[ ❤️ 생존 확인하기 ]` 버튼은 사이트를 다시 열 필요 없이 `/confirm?token=...` 링크로 바로 생존 확인을 갱신합니다. + +Google 계정에서는 일반 비밀번호가 아니라 **앱 비밀번호**를 사용해야 합니다. + 토큰이 있을 때 초기화: ```bash diff --git a/config/default.json b/config/default.json index eba2b6e..18a8132 100644 --- a/config/default.json +++ b/config/default.json @@ -9,5 +9,11 @@ "collection_path": "/home/zenn/Collection", "port": 40100, "recent_traces_limit": 5, - "reset_token": "" + "reset_token": "", + "public_base_url": "https://collection.sori.studio", + "mail_enabled": false, + "mail_smtp_user": "", + "mail_smtp_pass": "", + "mail_to": "zenn.message@gmail.com", + "mail_confirm_token": "" } diff --git a/docker-compose.yml b/docker-compose.yml index edbf536..0bd36c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,12 @@ services: COLLECTION_PATH: /home/zenn/Collection BACKGROUND: random RESET_TOKEN: ${RESET_TOKEN:?Set RESET_TOKEN in .env} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://collection.sori.studio} + MAIL_ENABLED: ${MAIL_ENABLED:-false} + MAIL_SMTP_USER: ${MAIL_SMTP_USER:-} + MAIL_SMTP_PASS: ${MAIL_SMTP_PASS:-} + MAIL_TO: ${MAIL_TO:-zenn.message@gmail.com} + MAIL_CONFIRM_TOKEN: ${MAIL_CONFIRM_TOKEN:-} volumes: - keeper-data:/data - ./config/default.json:/app/config/default.json:ro diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2fd7490..2c0e579 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -184,10 +184,18 @@ Docker에서는 `docker-compose.yml`도 함께 확인합니다. ```bash cat > .env <<'EOF' RESET_TOKEN=여기에-긴-비밀-토큰 +PUBLIC_BASE_URL=https://collection.sori.studio +MAIL_ENABLED=true +MAIL_SMTP_USER=zenn.message@gmail.com +MAIL_SMTP_PASS=구글-앱-비밀번호 +MAIL_TO=zenn.message@gmail.com +MAIL_CONFIRM_TOKEN=긴-랜덤-확인-토큰 EOF ``` -`.env` 파일이 없으면 Docker Compose 실행 시 `RESET_TOKEN` 설정 오류가 납니다. +`.env` 파일이 없거나 `RESET_TOKEN`이 없으면 Docker Compose 실행 시 오류가 납니다. + +메일 알림을 켜면 마지막 확인 이후 60일, 75일, 89일에 Gmail로 안내 메일이 한 번씩 발송됩니다. 메일 버튼은 `/confirm?token=...` 링크로 바로 생존 확인을 갱신합니다. ### 5-7. Docker 실행 @@ -227,6 +235,12 @@ http://NAS_IP:40100 | `port` | `40100` | 웹 포트 | | `recent_traces_limit` | `5` | 최근 흔적 개수 | | `reset_token` | `""` | 초기화 API 보호 토큰 | +| `public_base_url` | `https://collection.sori.studio` | 메일 확인 링크 공개 URL | +| `mail_enabled` | `false` | Gmail 알림 메일 사용 여부 | +| `mail_smtp_user` | `""` | Gmail SMTP 계정 | +| `mail_smtp_pass` | `""` | Gmail 앱 비밀번호 | +| `mail_to` | `zenn.message@gmail.com` | 알림 수신 주소 | +| `mail_confirm_token` | `""` | 메일 확인 링크 보호 토큰 | ### 6-2. 화면 문구 (`public/custom.js`) @@ -254,6 +268,12 @@ http://NAS_IP:40100 - `BACKGROUND` - `RESET_TOKEN` - `TZ` +- `PUBLIC_BASE_URL` +- `MAIL_ENABLED` +- `MAIL_SMTP_USER` +- `MAIL_SMTP_PASS` +- `MAIL_TO` +- `MAIL_CONFIRM_TOKEN` --- diff --git a/package-lock.json b/package-lock.json index ac7c376..37d287f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "better-sqlite3": "^11.10.0", - "express": "^4.21.2" + "express": "^4.21.2", + "nodemailer": "^6.10.1" }, "engines": { "node": ">=20" @@ -751,6 +752,15 @@ "node": ">=10" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index c1e6558..fce6f2c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "license": "MIT", "dependencies": { "better-sqlite3": "^11.10.0", - "express": "^4.21.2" + "express": "^4.21.2", + "nodemailer": "^6.10.1" } } diff --git a/public/app.js b/public/app.js index ddfc119..9a7082d 100644 --- a/public/app.js +++ b/public/app.js @@ -7,6 +7,19 @@ const timeMessageGroups = { afternoon: custom.afternoonMessages ?? [], evening: custom.eveningMessages ?? [], }; +const noticeMessageGroups = { + month: custom.monthVisitMessages ?? [], + longAbsent: custom.longAbsentMessages ?? [], + warning: custom.warningMessages ?? [], + final: custom.finalWarningMessages ?? [], +}; +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const NOTICE_THRESHOLDS = { + final: 3, + warning: 15, + longAbsent: 30, + month: 60, +}; const SEOUL_TIME_ZONE = 'Asia/Seoul'; const KEEPER_TEXT_LINES = custom.keeperTextLines?.length ? custom.keeperTextLines @@ -18,7 +31,7 @@ const FAREWELL_TEXT_LINES = custom.farewellTextLines?.length const elements = { backgroundLayer: document.getElementById('background-layer'), effectLayer: document.getElementById('effect-layer'), - toast: document.getElementById('toast'), + noticePanel: document.getElementById('notice-panel'), keeperText: document.getElementById('keeper-text'), farewellScreen: document.getElementById('farewell-screen'), lastCheck: document.getElementById('last-check'), @@ -28,12 +41,13 @@ const elements = { }; let currentStatus = null; -let toastTimer = null; let expired = false; let expiryTransitionStarted = false; let appliedBackground = null; let particleCleanupTimer = null; let expiryFinishTimer = null; +let sessionNoticeLevel = null; +let sessionNoticeMessage = null; function renderKeeperText() { elements.keeperText.replaceChildren(); @@ -116,14 +130,67 @@ function getSeoulHour(date) { return Number(hour); } -function showToast(message) { - window.clearTimeout(toastTimer); - elements.toast.textContent = message; - elements.toast.classList.add('is-visible'); +function showNotice(message, level = 'info') { + elements.noticePanel.textContent = message; + elements.noticePanel.className = `notice-panel is-${level}`; + elements.noticePanel.hidden = false; +} - toastTimer = window.setTimeout(() => { - elements.toast.classList.remove('is-visible'); - }, 2200); +function hideNotice() { + elements.noticePanel.hidden = true; + elements.noticePanel.textContent = ''; + elements.noticePanel.className = 'notice-panel'; +} + +function getNoticeLevel(status) { + const remainingMs = Math.max(0, new Date(status.deadline_at).getTime() - Date.now()); + const remainingDays = remainingMs / DAY_IN_MS; + + if (remainingDays <= NOTICE_THRESHOLDS.final) { + return 'final'; + } + + if (remainingDays <= NOTICE_THRESHOLDS.warning) { + return 'warning'; + } + + if (remainingDays <= NOTICE_THRESHOLDS.longAbsent) { + return 'longAbsent'; + } + + if (remainingDays <= NOTICE_THRESHOLDS.month) { + return 'month'; + } + + return null; +} + +function renderNotice(status) { + if (!status || status.expired || expired) { + hideNotice(); + return; + } + + const level = getNoticeLevel(status); + + if (!level) { + sessionNoticeLevel = null; + sessionNoticeMessage = null; + hideNotice(); + return; + } + + if (level !== sessionNoticeLevel) { + sessionNoticeLevel = level; + sessionNoticeMessage = randomFrom(noticeMessageGroups[level] ?? []); + } + + if (!sessionNoticeMessage) { + hideNotice(); + return; + } + + showNotice(sessionNoticeMessage, level); } function applyBackground(filename) { @@ -144,9 +211,9 @@ function updateStatus(status) { applyBackground(status.background); if (status.expired) { - if (wasInitialized && !wasExpired) { + if (!expiryTransitionStarted && wasInitialized && !wasExpired) { startExpiryTransition(); - } else { + } else if (!expiryTransitionStarted) { showExpiredImmediately(); } } else { @@ -154,6 +221,7 @@ function updateStatus(status) { } renderLiveStats(); + renderNotice(status); } function renderLiveStats() { @@ -411,14 +479,8 @@ async function handleCheck() { const result = await response.json(); updateStatus(result.status); - if (result.expired || result.status.expired) { - showToast('이미 시간이 지났어요.'); - return; - } - - showToast(result.accepted ? `${emoji} 방금 확인했어요.` : `${emoji} 잠시 후 다시 눌러주세요.`); } catch (error) { - showToast('잠시 문제가 생겼어요. 다시 눌러주세요.'); + showNotice('잠시 문제가 생겼어요. 다시 눌러주세요.', 'error'); } } @@ -426,7 +488,7 @@ document.addEventListener('click', handleCheck); renderKeeperText(); renderFarewellText(); loadStatus().catch(() => { - showToast('상태를 불러오지 못했어요.'); + showNotice('상태를 불러오지 못했어요.', 'error'); }); window.setInterval(renderLiveStats, 1000); diff --git a/public/index.html b/public/index.html index 4af7234..31c5c56 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ -
+
diff --git a/public/styles.css b/public/styles.css index 29193a8..96652d5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -49,24 +49,42 @@ body.is-expiring { overflow: hidden; } -.toast { +.notice-panel { position: fixed; top: 1.5rem; right: 1.5rem; z-index: 4; max-width: min(24rem, calc(100vw - 3rem)); + padding: 0.85rem 1rem; + border-radius: 1rem; color: var(--strong); font-size: 0.82rem; - line-height: 1.45; - opacity: 0; - pointer-events: none; - transform: translateY(-0.4rem); - transition: opacity 0.2s ease, transform 0.2s ease; + line-height: 1.55; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + text-shadow: 0 1px 8px rgba(255, 255, 255, 0.85); + backdrop-filter: blur(10px); } -.toast.is-visible { - opacity: 1; - transform: translateY(0); +.notice-panel.is-month, +.notice-panel.is-longAbsent { + font-weight: 500; +} + +.notice-panel.is-warning { + font-weight: 700; +} + +.notice-panel.is-final { + color: #c01818; + font-weight: 800; + text-shadow: + 0 1px 10px rgba(255, 255, 255, 0.95), + 0 0 18px rgba(192, 24, 24, 0.18); +} + +.notice-panel.is-error { + color: #8a1f1f; } .app { @@ -134,9 +152,10 @@ body.is-expiring .traces-panel { z-index: 4; pointer-events: none; line-height: 1; - animation: particle-disperse 1.65s cubic-bezier(0.2, 0.8, 0.2, 1) var(--particle-delay, 0s) forwards; + transform: translate(-50%, -50%); transform-origin: center; will-change: opacity, transform, filter; + animation: particle-disperse 1.65s cubic-bezier(0.2, 0.8, 0.2, 1) var(--particle-delay, 0s) forwards; } body.is-expired .keeper-panel, @@ -234,13 +253,15 @@ body.is-expired .traces-panel { } .click-message { - position: absolute; + position: fixed; max-width: min(28rem, calc(100vw - 3rem)); color: var(--strong); font-size: clamp(0.92rem, 2vw, 1.1rem); line-height: 1.5; white-space: nowrap; text-shadow: 0 1px 12px rgba(255, 255, 255, 0.95); + transform: translate(-50%, 0); + will-change: opacity, transform; animation: message-fade 1.8s ease forwards; } @@ -270,7 +291,7 @@ body.is-expired .traces-panel { 0% { opacity: 1; filter: blur(0); - transform: translate(0, 0) rotate(0deg) scale(1); + transform: translate(-50%, -50%) rotate(0deg) scale(1); } 55% { @@ -282,7 +303,10 @@ body.is-expired .traces-panel { opacity: 0; filter: blur(5px); transform: - translate(var(--particle-x, 3rem), var(--particle-y, -4rem)) + translate( + calc(-50% + var(--particle-x, 3rem)), + calc(-50% + var(--particle-y, -4rem)) + ) rotate(var(--particle-rotation, 6deg)) scale(0.86); } @@ -310,7 +334,7 @@ body.is-expired .traces-panel { } @media (max-width: 640px) { - .toast { + .notice-panel { top: 1rem; right: 1rem; } diff --git a/src/config.js b/src/config.js index 9216106..43eda75 100644 --- a/src/config.js +++ b/src/config.js @@ -34,6 +34,12 @@ function loadConfig() { process.env.RECENT_TRACES_LIMIT ?? fileConfig.recent_traces_limit ?? 5 ), reset_token: process.env.RESET_TOKEN ?? fileConfig.reset_token ?? '', + public_base_url: process.env.PUBLIC_BASE_URL ?? fileConfig.public_base_url ?? '', + mail_enabled: parseBoolean(process.env.MAIL_ENABLED ?? fileConfig.mail_enabled, false), + mail_smtp_user: process.env.MAIL_SMTP_USER ?? fileConfig.mail_smtp_user ?? '', + mail_smtp_pass: process.env.MAIL_SMTP_PASS ?? fileConfig.mail_smtp_pass ?? '', + mail_to: process.env.MAIL_TO ?? fileConfig.mail_to ?? '', + mail_confirm_token: process.env.MAIL_CONFIRM_TOKEN ?? fileConfig.mail_confirm_token ?? '', root_dir: ROOT_DIR, config_path: configPath, }; @@ -44,6 +50,18 @@ function loadConfig() { return config; } +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + if (typeof value === 'boolean') { + return value; + } + + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + function optionalNumber(value) { if (value === undefined || value === null || value === '') { return null; diff --git a/src/confirmPage.js b/src/confirmPage.js new file mode 100644 index 0000000..157e6f6 --- /dev/null +++ b/src/confirmPage.js @@ -0,0 +1,98 @@ +function renderConfirmSuccessPage() { + return ` + + + + + Collection Keeper + + + +
+

❤️ 확인 완료

+

다시 와줘서 고마워.

+

컬렉션은 계속 안전하게 보관될 거야.

+
+ +`; +} + +function renderConfirmErrorPage(message) { + return ` + + + + + Collection Keeper + + + +
+

확인할 수 없어요

+

${escapeHtml(message)}

+
+ +`; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +module.exports = { + renderConfirmSuccessPage, + renderConfirmErrorPage, +}; diff --git a/src/db.js b/src/db.js index 1a80669..f7faace 100644 --- a/src/db.js +++ b/src/db.js @@ -41,6 +41,14 @@ function createDatabase(databasePath) { output TEXT ); + CREATE TABLE IF NOT EXISTS email_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + milestone INTEGER NOT NULL, + last_check_at TEXT NOT NULL, + sent_at TEXT NOT NULL, + UNIQUE(milestone, last_check_at) + ); + CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC); `); @@ -283,22 +291,70 @@ function resetKeeper(db) { const deleteEvents = db.prepare('DELETE FROM events'); const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs'); + const deleteEmailNotifications = db.prepare('DELETE FROM email_notifications'); const transaction = db.transaction(() => { resetState.run(nowIso); deleteEvents.run(); deleteTriggerRuns.run(); + deleteEmailNotifications.run(); }); transaction(); } +const EMAIL_MILESTONES = [60, 75, 89]; + +function hasEmailBeenSent(db, milestone, lastCheckAt) { + const row = db + .prepare(` + SELECT id + FROM email_notifications + WHERE milestone = ? AND last_check_at = ? + `) + .get(milestone, lastCheckAt); + + return Boolean(row); +} + +function markEmailSent(db, milestone, lastCheckAt) { + db.prepare(` + INSERT OR IGNORE INTO email_notifications (milestone, last_check_at, sent_at) + VALUES (?, ?, ?) + `).run(milestone, lastCheckAt, new Date().toISOString()); +} + +function getPendingEmailAlert(db, config) { + const state = getStateRow(db); + + if (isExpired(state, config)) { + return null; + } + + const status = buildStatus(db, config); + + for (const milestone of EMAIL_MILESTONES) { + if (status.elapsed_days >= milestone && !hasEmailBeenSent(db, milestone, state.last_check_at)) { + return { + milestone, + status, + }; + } + } + + return null; +} + module.exports = { FALLBACK_EMOJIS, + EMAIL_MILESTONES, createDatabase, buildStatus, recordCheck, shouldRunTrigger, markTriggerExecuted, resetKeeper, + hasEmailBeenSent, + markEmailSent, + getPendingEmailAlert, }; diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 0000000..7b39935 --- /dev/null +++ b/src/mailer.js @@ -0,0 +1,156 @@ +const nodemailer = require('nodemailer'); + +const EMAIL_TEMPLATES = { + 60: { + subject: '[Collection Keeper] 오랜만이네.', + headline: '오랜만이네.', + body: [ + '안녕.', + '', + '마지막 확인 이후 60일이 지났어.', + '', + '컬렉션은 아직 안전하게 보관되고 있어.', + '', + '혹시 잊고 있었더라도 괜찮아.', + '아래 버튼을 누르면 바로 확인할 수 있어.', + ], + }, + 75: { + subject: '[Collection Keeper] 조금 걱정하고 있어.', + headline: '조금 걱정하고 있어.', + body: [ + '안녕.', + '', + '마지막 확인 이후 75일이 지났어.', + '', + '아직은 괜찮지만,', + '생각보다 긴 시간이 흘렀네.', + '', + '컬렉션은 계속 기다리고 있어.', + ], + }, + 89: { + subject: '[Collection Keeper] 정말 마지막 안내야.', + headline: '정말 마지막 안내야.', + body: [ + '안녕.', + '', + '마지막 확인 이후 89일이 지났어.', + '', + '이제 하루만 지나면 설정된 작업이 실행될 예정이야.', + '', + '혹시 메일을 보고 있다면', + '아래 버튼을 눌러줘.', + '', + '버튼 하나면 충분해.', + ], + }, +}; + +function createTransport(config) { + return nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + user: config.mail_smtp_user, + pass: config.mail_smtp_pass, + }, + }); +} + +function buildConfirmUrl(config) { + const baseUrl = config.public_base_url.replace(/\/$/, ''); + const token = encodeURIComponent(config.mail_confirm_token); + return `${baseUrl}/confirm?token=${token}`; +} + +function buildEmailContent(milestone, status, confirmUrl) { + const template = EMAIL_TEMPLATES[milestone]; + + if (!template) { + throw new Error(`Unknown email milestone: ${milestone}`); + } + + const textLines = [ + template.headline, + '', + ...template.body, + '', + '[ ❤️ 생존 확인하기 ]', + confirmUrl, + '', + `현재 경과 : ${status.elapsed_days}일`, + `남은 기간 : ${status.remaining_days}일`, + ]; + + const htmlBody = template.body.map((line) => (line ? `

${escapeHtml(line)}

` : '
')).join(''); + + const html = ` + + +

${escapeHtml(template.headline)}

+ ${htmlBody} +

+ + ❤️ 생존 확인하기 + +

+

+ 현재 경과 : ${status.elapsed_days}일
+ 남은 기간 : ${status.remaining_days}일 +

+ +`; + + return { + subject: template.subject, + text: textLines.join('\n'), + html, + }; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function isMailConfigured(config) { + return Boolean( + config.mail_enabled && + config.mail_smtp_user && + config.mail_smtp_pass && + config.mail_to && + config.mail_confirm_token && + config.public_base_url + ); +} + +async function sendAlertEmail(config, milestone, status) { + if (!isMailConfigured(config)) { + throw new Error('Mail is not fully configured.'); + } + + const transport = createTransport(config); + const confirmUrl = buildConfirmUrl(config); + const content = buildEmailContent(milestone, status, confirmUrl); + + await transport.sendMail({ + from: `"Collection Keeper" <${config.mail_smtp_user}>`, + to: config.mail_to, + subject: content.subject, + text: content.text, + html: content.html, + }); +} + +module.exports = { + EMAIL_TEMPLATES, + buildConfirmUrl, + buildEmailContent, + isMailConfigured, + sendAlertEmail, +}; diff --git a/src/server.js b/src/server.js index 7336605..20efd0a 100644 --- a/src/server.js +++ b/src/server.js @@ -8,15 +8,20 @@ const { shouldRunTrigger, markTriggerExecuted, resetKeeper, + getPendingEmailAlert, + markEmailSent, } = require('./db'); const { runTriggerScript } = require('./trigger'); const { getVersionInfo } = require('./version'); +const { isMailConfigured, sendAlertEmail } = require('./mailer'); +const { renderConfirmSuccessPage, renderConfirmErrorPage } = require('./confirmPage'); const config = loadConfig(); const db = createDatabase(config.database_path); const app = express(); let triggerRunning = false; +let emailRunning = false; const sessionBackground = pickBackground(config); app.use(express.json()); @@ -53,6 +58,27 @@ app.post('/api/reset', (req, res) => { }); }); +app.get('/confirm', (req, res) => { + if (!config.mail_confirm_token) { + res.status(503).send(renderConfirmErrorPage('메일 확인 기능이 설정되어 있지 않아요.')); + return; + } + + if (req.query.token !== config.mail_confirm_token) { + res.status(403).send(renderConfirmErrorPage('확인 링크가 올바르지 않아요.')); + return; + } + + const result = recordCheck(db, config, '❤️'); + + if (result.expired) { + res.status(410).send(renderConfirmErrorPage('이미 시간이 지났어요.')); + return; + } + + res.type('html').send(renderConfirmSuccessPage()); +}); + app.get('*', (_req, res) => { res.sendFile(path.join(config.root_dir, 'public', 'index.html')); }); @@ -91,10 +117,38 @@ async function evaluateTrigger() { } } +async function evaluateEmailAlerts() { + if (emailRunning || !isMailConfigured(config)) { + return; + } + + const pending = getPendingEmailAlert(db, config); + + if (!pending) { + return; + } + + emailRunning = true; + + try { + console.log(`[keeper] Sending email alert for milestone ${pending.milestone} days.`); + await sendAlertEmail(config, pending.milestone, pending.status); + markEmailSent(db, pending.milestone, pending.status.last_check_at); + console.log(`[keeper] Email alert sent for milestone ${pending.milestone} days.`); + } catch (error) { + console.error('[keeper] Email alert failed:', error.message); + } finally { + emailRunning = false; + } +} + setInterval(() => { evaluateTrigger().catch((error) => { console.error('[keeper] Trigger scheduler error:', error.message); }); + evaluateEmailAlerts().catch((error) => { + console.error('[keeper] Email scheduler error:', error.message); + }); }, config.check_interval_seconds * 1000); app.listen(config.port, () => { @@ -102,7 +156,11 @@ app.listen(config.port, () => { console.log(`[keeper] ${name} v${version} listening on port ${config.port}`); console.log(`[keeper] Database: ${config.database_path}`); console.log(`[keeper] Trigger script: ${config.trigger_script}`); + console.log(`[keeper] Mail alerts: ${isMailConfigured(config) ? 'enabled' : 'disabled'}`); evaluateTrigger().catch((error) => { console.error('[keeper] Initial trigger check failed:', error.message); }); + evaluateEmailAlerts().catch((error) => { + console.error('[keeper] Initial email check failed:', error.message); + }); });