diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d3e0bc3..ab078c3 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -37,11 +37,11 @@ - [ ] Deadline kontrolü - [ ] Status güncellemeleri -### Faz 6: Email Gönderimi 🔲 -- [ ] Nodemailer setup -- [ ] Email templates -- [ ] Deadline triggered email -- [ ] Check-in reminder emails +### Faz 6: Email Gönderimi ✅ +- [x] Nodemailer setup +- [x] Email templates +- [x] Deadline triggered email +- [x] Check-in reminder emails ### Faz 7: Admin Panel 🔲 - [ ] Admin role ve guard @@ -81,6 +81,7 @@ - ✅ Faz 2: Mesaj CRUD - ✅ Faz 3: Dosya Yükleme (R2) - ✅ Faz 4: Client-side Encryption +- ✅ Faz 6: Email Gönderimi ## Bilinen Sorunlar (henüz yok) diff --git a/package-lock.json b/package-lock.json index 9756cd6..9ceadec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "lucide-react": "^0.562.0", "next": "^16.1.1", "next-auth": "^4.24.13", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -30,6 +31,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20.19.27", + "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -391,6 +393,58 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.958.0.tgz", + "integrity": "sha512-3x3n8IIxIMAkdpt9wy9zS7MO2lqTcJwQTdHMn6BlD7YUohb+r5Q4KCOEQ2uHWd4WIJv2tlbXnfypHaXReO/WXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-node": "3.958.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/signature-v4-multi-region": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sso": { "version": "3.958.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", @@ -3740,6 +3794,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz", + "integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -7757,6 +7822,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", diff --git a/package.json b/package.json index e6dcc3f..f69f471 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lucide-react": "^0.562.0", "next": "^16.1.1", "next-auth": "^4.24.13", + "nodemailer": "^7.0.12", "pg": "^8.16.3", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -31,6 +32,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20.19.27", + "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/src/app/api/cron/check-deadlines/route.ts b/src/app/api/cron/check-deadlines/route.ts new file mode 100644 index 0000000..55e7efa --- /dev/null +++ b/src/app/api/cron/check-deadlines/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { sendEmail, emailTemplates } from '@/lib/email'; + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const now = new Date(); + + const expiredMessages = await prisma.message.findMany({ + where: { + status: 'ACTIVE', + lastPing: { + lt: new Date(now.getTime() - 1000 * 60 * 60 * 24), // 24 saat geçmiş + }, + }, + include: { + user: true, + recipients: true, + }, + }); + + const results = []; + + for (const message of expiredMessages) { + const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000); + + if (now > deadlineTime) { + for (const recipient of message.recipients) { + const template = emailTemplates.deadlineTriggered( + recipient.email, + message.user.name || 'Bilinmeyen', + message.id + ); + + try { + await sendEmail({ + to: recipient.email, + subject: template.subject, + html: template.html, + }); + results.push({ messageId: message.id, recipient: recipient.email, status: 'sent' }); + } catch (error) { + results.push({ messageId: message.id, recipient: recipient.email, status: 'failed', error }); + } + } + + await prisma.message.update({ + where: { id: message.id }, + data: { status: 'TRIGGERED' }, + }); + } + } + + return NextResponse.json({ checked: expiredMessages.length, results }); +} diff --git a/src/app/api/cron/send-reminders/route.ts b/src/app/api/cron/send-reminders/route.ts new file mode 100644 index 0000000..9bb200e --- /dev/null +++ b/src/app/api/cron/send-reminders/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { sendEmail, emailTemplates } from '@/lib/email'; + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const now = new Date(); + const reminderHours = [24, 12, 6, 1]; + const results = []; + + const activeMessages = await prisma.message.findMany({ + where: { status: 'ACTIVE' }, + include: { user: true }, + }); + + for (const message of activeMessages) { + const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000); + const hoursLeft = (deadlineTime.getTime() - now.getTime()) / (1000 * 60 * 60); + + for (const reminderHour of reminderHours) { + if (hoursLeft <= reminderHour && hoursLeft > reminderHour - 1) { + if (message.user.email) { + const template = emailTemplates.checkInReminder( + message.user.name || 'Kullanıcı', + Math.round(hoursLeft), + message.title + ); + + try { + await sendEmail({ + to: message.user.email, + subject: template.subject, + html: template.html, + }); + results.push({ messageId: message.id, hoursLeft: Math.round(hoursLeft), status: 'sent' }); + } catch (error) { + results.push({ messageId: message.id, status: 'failed', error }); + } + } + break; + } + } + } + + return NextResponse.json({ checked: activeMessages.length, reminders: results }); +} diff --git a/src/app/decrypt/page.tsx b/src/app/decrypt/page.tsx index 2e4a9d7..c074ea3 100644 --- a/src/app/decrypt/page.tsx +++ b/src/app/decrypt/page.tsx @@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Lock, Unlock } from "lucide-react"; -import { deriveKeyFromPassword, decrypt, decryptFile } from "@/lib/crypto"; +import { decrypt, decryptFile } from "@/lib/crypto"; export default function DecryptPage() { const [encryptedContent, setEncryptedContent] = useState(""); @@ -20,8 +20,7 @@ export default function DecryptPage() { setLoading(true); setError(null); try { - const key = await deriveKeyFromPassword(password); - const decrypted = await decrypt(encryptedContent, key); + const decrypted = await decrypt(encryptedContent, password); setDecryptedContent(decrypted); } catch { setError("Şifre çözme başarısız. Şifre veya içerik hatalı olabilir."); @@ -37,14 +36,13 @@ export default function DecryptPage() { setLoading(true); setError(null); try { - const key = await deriveKeyFromPassword(password); - const decryptedData = await decryptFile(file, key); + const decryptedData = await decryptFile(file, password); let originalName = file.name; if (file.name.endsWith(".enc")) { const encryptedName = file.name.slice(0, -4); try { - originalName = await decrypt(encryptedName, key); + originalName = await decrypt(encryptedName, password); } catch { originalName = "decrypted_file"; } diff --git a/src/components/attachments/file-upload.tsx b/src/components/attachments/file-upload.tsx index 121bca6..fda081e 100644 --- a/src/components/attachments/file-upload.tsx +++ b/src/components/attachments/file-upload.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { deriveKeyFromPassword, encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto"; +import { encrypt, decrypt, encryptFile, decryptFile } from "@/lib/crypto"; interface FileUploadProps { messageId: string; @@ -31,9 +31,8 @@ export function FileUpload({ messageId, isEncrypted, password, onUploadComplete let contentType = file.type; if (isEncrypted && password) { - const key = await deriveKeyFromPassword(password); - fileToUpload = await encryptFile(file, key); - fileName = (await encrypt(file.name, key)) + ".enc"; + fileToUpload = await encryptFile(file, password); + fileName = (await encrypt(file.name, password)) + ".enc"; contentType = "application/octet-stream"; } @@ -99,19 +98,18 @@ export function AttachmentList({ attachments, onDelete, isEncrypted, password }: try { const res = await fetch(`/api/attachments/${id}`); const { downloadUrl } = await res.json(); - + if (isEncrypted && password && fileName.endsWith(".enc")) { const fileRes = await fetch(downloadUrl); const encryptedBlob = await fileRes.blob(); - const key = await deriveKeyFromPassword(password); - const decryptedData = await decryptFile(encryptedBlob, key); - + const decryptedData = await decryptFile(encryptedBlob, password); + let originalName = "decrypted_file"; try { const encryptedName = fileName.slice(0, -4); - originalName = await decrypt(encryptedName, key); + originalName = await decrypt(encryptedName, password); } catch {} - + const blob = new Blob([decryptedData]); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/src/components/messages/message-form.tsx b/src/components/messages/message-form.tsx index 1fd8315..9e8804b 100644 --- a/src/components/messages/message-form.tsx +++ b/src/components/messages/message-form.tsx @@ -9,7 +9,7 @@ import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/componen import { Plus, Trash2, Lock, Unlock } from "lucide-react"; import { useRouter } from "next/navigation"; import { FileUpload, AttachmentList } from "@/components/attachments/file-upload"; -import { deriveKeyFromPassword, encrypt, decrypt, encryptFile } from "@/lib/crypto"; +import { encrypt, decrypt, encryptFile } from "@/lib/crypto"; interface Attachment { id: string; @@ -75,8 +75,7 @@ export default function MessageForm({ initialData }: MessageFormProps) { setDecryptError(null); setLoading(true); try { - const key = await deriveKeyFromPassword(password); - const decryptedContent = await decrypt(content, key); + const decryptedContent = await decrypt(content, password); setContent(decryptedContent); setIsDecrypted(true); } catch { @@ -97,12 +96,10 @@ export default function MessageForm({ initialData }: MessageFormProps) { setLoading(true); try { - let encryptionKey: string | null = null; let finalContent = content; if (isEncrypted) { - encryptionKey = await deriveKeyFromPassword(password); - finalContent = await encrypt(content, encryptionKey); + finalContent = await encrypt(content, password); } const url = initialData?.id @@ -130,9 +127,9 @@ export default function MessageForm({ initialData }: MessageFormProps) { let fileToUpload: Blob = file; let fileName = file.name; - if (isEncrypted && encryptionKey) { - fileToUpload = await encryptFile(file, encryptionKey); - const encryptedFileName = await encrypt(file.name, encryptionKey); + if (isEncrypted) { + fileToUpload = await encryptFile(file, password); + const encryptedFileName = await encrypt(file.name, password); fileName = encryptedFileName + ".enc"; } diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 6d75911..22870d3 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,4 +1,4 @@ -export async function deriveKeyFromPassword(password: string): Promise { +async function deriveKey(password: string, salt: Uint8Array): Promise { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( "raw", @@ -7,90 +7,61 @@ export async function deriveKeyFromPassword(password: string): Promise { false, ["deriveBits", "deriveKey"] ); - const key = await crypto.subtle.deriveKey( + return crypto.subtle.deriveKey( { name: "PBKDF2", - salt: encoder.encode("dead-man-switch-salt"), + salt: salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer, iterations: 100000, hash: "SHA-256", }, keyMaterial, { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"] - ); - const exported = await crypto.subtle.exportKey("raw", key); - return btoa(String.fromCharCode(...new Uint8Array(exported))); -} - -export async function generateKey(): Promise { - const key = await crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - true, - ["encrypt", "decrypt"] - ); - const exported = await crypto.subtle.exportKey("raw", key); - return btoa(String.fromCharCode(...new Uint8Array(exported))); -} - -export async function importKey(keyString: string): Promise { - const keyData = Uint8Array.from(atob(keyString), (c) => c.charCodeAt(0)); - return crypto.subtle.importKey( - "raw", - keyData, - { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } -export async function encrypt(data: string, keyString: string): Promise { - const key = await importKey(keyString); +export async function encrypt(data: string, password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); const encoded = new TextEncoder().encode(data); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - encoded - ); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); + const combined = new Uint8Array(16 + 12 + encrypted.byteLength); + combined.set(salt); + combined.set(iv, 16); + combined.set(new Uint8Array(encrypted), 28); return btoa(String.fromCharCode(...combined)); } -export async function decrypt(encryptedData: string, keyString: string): Promise { - const key = await importKey(keyString); +export async function decrypt(encryptedData: string, password: string): Promise { const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)); - const iv = combined.slice(0, 12); - const data = combined.slice(12); - const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - data - ); + const salt = combined.slice(0, 16); + const iv = combined.slice(16, 28); + const data = combined.slice(28); + const key = await deriveKey(password, salt); + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data); return new TextDecoder().decode(decrypted); } -export async function encryptFile(file: File, keyString: string): Promise { - const key = await importKey(keyString); +export async function encryptFile(file: File, password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); const arrayBuffer = await file.arrayBuffer(); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - arrayBuffer - ); - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, arrayBuffer); + const combined = new Uint8Array(16 + 12 + encrypted.byteLength); + combined.set(salt); + combined.set(iv, 16); + combined.set(new Uint8Array(encrypted), 28); return new Blob([combined], { type: "application/octet-stream" }); } -export async function decryptFile(encryptedBlob: Blob, keyString: string): Promise { - const key = await importKey(keyString); +export async function decryptFile(encryptedBlob: Blob, password: string): Promise { const combined = new Uint8Array(await encryptedBlob.arrayBuffer()); - const iv = combined.slice(0, 12); - const data = combined.slice(12); + const salt = combined.slice(0, 16); + const iv = combined.slice(16, 28); + const data = combined.slice(28); + const key = await deriveKey(password, salt); return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data); } diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..17e860d --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,150 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +interface EmailOptions { + to: string; + subject: string; + html: string; +} + +export async function sendEmail({ to, subject, html }: EmailOptions) { + return transporter.sendMail({ + from: process.env.SMTP_FROM || 'Dead Man Switch ', + to, + subject, + html, + }); +} + +export const emailTemplates = { + deadlineTriggered: (recipientEmail: string, senderName: string, messageId: string) => ({ + subject: `Önemli Mesaj: ${escapeHtml(senderName)} tarafından gönderildi`, + html: ` + + + + + + + +
+
+

Dead Man's Switch Tetiklendi

+
+
+

Merhaba,

+

${escapeHtml(senderName)} tarafından size önceden hazırlanmış bir mesaj bırakıldı.

+

Bu mesaj, belirlenen süre içinde check-in yapılmadığı için otomatik olarak gönderilmiştir.

+

Mesajı görüntülemek için aşağıdaki bağlantıya tıklayın:

+ Mesajı Görüntüle +

Mesaj şifreli ise, gönderen tarafından size iletilen şifreyi girmeniz gerekecektir.

+
+ +
+ + + `, + }), + + checkInReminder: (userName: string, hoursLeft: number, switchTitle: string) => ({ + subject: `Hatırlatma: ${escapeHtml(switchTitle)} için check-in gerekli`, + html: ` + + + + + + + +
+
+

Check-in Hatırlatması

+
+
+

Merhaba ${escapeHtml(userName)},

+
+ "${escapeHtml(switchTitle)}" için son ${hoursLeft} saat kaldı! +
+

Mesajlarınızın gönderilmemesi için lütfen check-in yapın.

+ Şimdi Check-in Yap +
+ +
+ + + `, + }), + + welcomeEmail: (userName: string) => ({ + subject: 'Dead Man\'s Switch\'e Hoş Geldiniz', + html: ` + + + + + + + +
+
+

Hoş Geldiniz!

+
+
+

Merhaba ${escapeHtml(userName)},

+

Dead Man's Switch sistemine kayıt olduğunuz için teşekkür ederiz.

+

Artık önemli mesajlarınızı güvenle saklayabilir ve belirlediğiniz kişilere iletilmesini sağlayabilirsiniz.

+ Dashboard'a Git +
+ +
+ + + `, + }), +};