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);
+ });
});