Gmail 알림 메일과 남은 기간 안내 메시지 추가

메일 확인 링크로 생존 확인을 갱신하고, 화면 토스트 대신 남은 기간별 안내 문구를 표시합니다.
This commit is contained in:
2026-06-10 13:52:15 +09:00
parent 5f9f10f305
commit d56c6ea1ac
14 changed files with 584 additions and 38 deletions

View File

@@ -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

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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`
---

12
package-lock.json generated
View File

@@ -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",

View File

@@ -13,6 +13,7 @@
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.10.0",
"express": "^4.21.2"
"express": "^4.21.2",
"nodemailer": "^6.10.1"
}
}

View File

@@ -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);

View File

@@ -9,7 +9,7 @@
<body>
<div id="background-layer" class="background-layer" aria-hidden="true"></div>
<div id="effect-layer" class="effect-layer" aria-hidden="true"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="notice-panel" class="notice-panel" role="status" aria-live="polite" hidden></div>
<main class="app">
<section id="click-stage" class="click-stage" aria-label="생존 확인 클릭 영역"></section>

View File

@@ -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;
}

View File

@@ -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;

98
src/confirmPage.js Normal file
View File

@@ -0,0 +1,98 @@
function renderConfirmSuccessPage() {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collection Keeper</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
color: rgba(20, 20, 20, 0.82);
background: #fff;
}
main {
text-align: center;
line-height: 2;
padding: 2rem;
}
h1 {
margin: 0 0 1rem;
font-size: 1.4rem;
font-weight: 600;
}
p {
margin: 0;
}
</style>
</head>
<body>
<main>
<h1>❤️ 확인 완료</h1>
<p>다시 와줘서 고마워.</p>
<p>컬렉션은 계속 안전하게 보관될 거야.</p>
</main>
</body>
</html>`;
}
function renderConfirmErrorPage(message) {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collection Keeper</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
color: rgba(20, 20, 20, 0.82);
background: #fff;
}
main {
text-align: center;
line-height: 2;
padding: 2rem;
}
h1 {
margin: 0 0 1rem;
font-size: 1.2rem;
font-weight: 600;
}
p {
margin: 0;
color: rgba(42, 42, 42, 0.72);
}
</style>
</head>
<body>
<main>
<h1>확인할 수 없어요</h1>
<p>${escapeHtml(message)}</p>
</main>
</body>
</html>`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
module.exports = {
renderConfirmSuccessPage,
renderConfirmErrorPage,
};

View File

@@ -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,
};

156
src/mailer.js Normal file
View File

@@ -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 ? `<p>${escapeHtml(line)}</p>` : '<br />')).join('');
const html = `<!DOCTYPE html>
<html lang="ko">
<body style="font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; color: #2a2a2a; line-height: 1.8;">
<p style="font-size: 18px; font-weight: 600;">${escapeHtml(template.headline)}</p>
${htmlBody}
<p style="margin-top: 24px;">
<a href="${confirmUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #2a2a2a; color: #fff; text-decoration: none; font-weight: 600;">
❤️ 생존 확인하기
</a>
</p>
<p style="margin-top: 24px; color: #666;">
현재 경과 : ${status.elapsed_days}일<br />
남은 기간 : ${status.remaining_days}일
</p>
</body>
</html>`;
return {
subject: template.subject,
text: textLines.join('\n'),
html,
};
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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,
};

View File

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