Gmail 알림 메일과 남은 기간 안내 메시지 추가
메일 확인 링크로 생존 확인을 갱신하고, 화면 토스트 대신 남은 기간별 안내 문구를 표시합니다.
This commit is contained in:
31
README.md
31
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
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"nodemailer": "^6.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
100
public/app.js
100
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
98
src/confirmPage.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderConfirmSuccessPage,
|
||||
renderConfirmErrorPage,
|
||||
};
|
||||
56
src/db.js
56
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,
|
||||
};
|
||||
|
||||
156
src/mailer.js
Normal file
156
src/mailer.js
Normal 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, '&')
|
||||
.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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user