🪻 distributed transcription service thistle.dunkirk.sh

feat: add email support

dunkirk.sh fc917bb0 b406ff86

verified
+9
.env.example
···
# Environment (set to 'production' in production)
NODE_ENV=development
+
+
# Email Configuration (MailChannels)
+
# DKIM private key for email authentication (required for sending emails)
+
# Generate: openssl genrsa -out dkim-private.pem 2048
+
# Then add TXT record: mailchannels._domainkey.yourdomain.com
+
DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
+
DKIM_DOMAIN=thistle.app
+
SMTP_FROM_EMAIL=noreply@thistle.app
+
SMTP_FROM_NAME=Thistle
+1
.gitignore
···
uploads/
transcripts/
.env
+
*.pem
-264
docs/CLASS_SYSTEM_SPEC.md
···
-
# Class System Specification
-
-
## Overview
-
-
Restructure Thistle from individual transcript management to class-based transcript organization. Users will manage transcripts grouped by classes, with scheduled meeting times and selective transcription.
-
-
## User Flow
-
-
### 1. Classes Page (Home)
-
- Replaces the transcript page as the main view after signup
-
- Displays grid of class cards organized by semester/year
-
- Each section (semester/year combo) separated by horizontal rules
-
- Each card shows:
-
- Course code (e.g., "CS 101")
-
- Course name (e.g., "Introduction to Computer Science")
-
- Professor name
-
- Semester and year (e.g., "Fall 2024")
-
- Archive indicator (if archived)
-
- Final card in grid is "Register for Class" with centered plus icon
-
- Empty state: Only shows register button if user has no classes
-
-
### 2. Individual Class Page (`/classes/:id`)
-
- Lists all recordings and transcripts for the class
-
- Shows meeting schedule (flexible text, e.g., "Monday Lecture", "Wednesday Lab")
-
- Displays recordings with statuses:
-
- **Pending**: Uploaded but not selected for transcription
-
- **Selected**: Marked for transcription by admin
-
- **Transcribed**: Processing complete, ready to view
-
- **Failed**: Transcription failed
-
- Upload button to add new recordings
-
- Each recording tagged with meeting time
-
-
### 3. Recording Upload
-
- Any enrolled student can upload recordings
-
- Must select which meeting time the recording is for
-
- Recording enters "pending" state
-
- Does not auto-transcribe
-
-
### 4. Admin Workflow
-
- Admin views pending recordings
-
- Selects specific recording to transcribe for each meeting
-
- Only selected recordings get processed
-
- Can manage classes (create, archive, enrollments)
-
-
## Database Schema
-
-
### Classes Table
-
```sql
-
CREATE TABLE classes (
-
id TEXT PRIMARY KEY, -- stable random ID (nanoid or similar)
-
course_code TEXT NOT NULL, -- e.g., "CS 101"
-
name TEXT NOT NULL, -- e.g., "Introduction to Computer Science"
-
professor TEXT NOT NULL,
-
semester TEXT NOT NULL, -- e.g., "Fall", "Spring", "Summer"
-
year INTEGER NOT NULL, -- e.g., 2024
-
archived BOOLEAN DEFAULT FALSE,
-
created_at INTEGER NOT NULL
-
);
-
```
-
-
### Class Members Table
-
```sql
-
CREATE TABLE class_members (
-
class_id TEXT NOT NULL,
-
user_id TEXT NOT NULL,
-
enrolled_at INTEGER NOT NULL,
-
PRIMARY KEY (class_id, user_id),
-
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
-
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-
);
-
```
-
-
### Meeting Times Table
-
```sql
-
CREATE TABLE meeting_times (
-
id TEXT PRIMARY KEY,
-
class_id TEXT NOT NULL,
-
label TEXT NOT NULL, -- flexible text: "Monday Lecture", "Wednesday Lab", etc.
-
created_at INTEGER NOT NULL,
-
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
-
);
-
```
-
-
### Updated Transcripts Table
-
```sql
-
-- Add new columns to existing transcripts table:
-
ALTER TABLE transcripts ADD COLUMN class_id TEXT;
-
ALTER TABLE transcripts ADD COLUMN meeting_time_id TEXT;
-
ALTER TABLE transcripts ADD COLUMN status TEXT DEFAULT 'pending';
-
-- status: 'pending' | 'selected' | 'transcribed' | 'failed'
-
-
-- Add foreign keys:
-
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
-
FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
-
```
-
-
**Note**: Add indexes for performance:
-
- `class_members(user_id)` - lookup user's classes
-
- `class_members(class_id)` - lookup class members
-
- `transcripts(class_id)` - lookup class transcripts
-
- `transcripts(status)` - filter by status
-
- `meeting_times(class_id)` - lookup class schedule
-
-
## Permissions
-
-
### Class Access
-
- Users can only view classes they're enrolled in
-
- Admins can view all classes
-
- Non-enrolled users get 403 when accessing `/classes/:id`
-
-
### Recording Permissions
-
- **Upload**: Any enrolled student can upload recordings
-
- **Delete**: Students can delete their own recordings
-
- **Select for transcription**: Admin only
-
- **View**: All enrolled students can view all transcripts in their classes
-
-
### Class Management
-
- **Create**: Admin only (via admin UI)
-
- **Archive**: Admin only (via admin UI)
-
- **Enroll students**: Admin only (via admin UI)
-
- **Remove students**: Admin only (via admin UI)
-
-
## Archive Behavior
-
-
When a class is archived:
-
- Students can still view the class and all transcripts
-
- No new recordings can be uploaded
-
- No recordings can be deleted
-
- No transcription selection allowed
-
- No enrollment changes
-
- Class appears with archive indicator in UI
-
- Organized with active classes by semester/year
-
-
## API Endpoints
-
-
### Classes
-
- `GET /api/classes` - List user's classes (grouped by semester/year)
-
- `GET /api/classes/:id` - Get class details (info, meeting times, transcripts)
-
- `POST /api/classes` (admin) - Create new class
-
- `PUT /api/classes/:id/archive` (admin) - Archive/unarchive class
-
- `DELETE /api/classes/:id` (admin) - Delete class
-
-
### Class Members
-
- `POST /api/classes/:id/members` (admin) - Enroll student(s)
-
- `DELETE /api/classes/:id/members/:userId` (admin) - Remove student
-
- `GET /api/classes/:id/members` (admin) - List class members
-
-
### Meeting Times
-
- `GET /api/classes/:id/meetings` - List meeting times
-
- `POST /api/classes/:id/meetings` (admin) - Create meeting time
-
- `PUT /api/meetings/:id` (admin) - Update meeting time label
-
- `DELETE /api/meetings/:id` (admin) - Delete meeting time
-
-
### Recordings/Transcripts
-
- `GET /api/classes/:id/transcripts` - List all transcripts for class
-
- `POST /api/classes/:id/recordings` - Upload recording (enrolled students)
-
- `PUT /api/transcripts/:id/select` (admin) - Mark recording for transcription
-
- `DELETE /api/transcripts/:id` - Delete recording (owner or admin)
-
- `GET /api/transcripts/:id` - View transcript (enrolled students)
-
-
## Frontend Components
-
-
### Pages
-
- `/classes` - Classes grid (home page, replaces transcripts page)
-
- `/classes/:id` - Individual class view
-
- `/admin` - Update to include class management
-
-
### New Components
-
- `class-card.ts` - Class card component
-
- `register-card.ts` - Register for class card (plus icon)
-
- `class-detail.ts` - Individual class page
-
- `recording-upload.ts` - Recording upload form
-
- `recording-list.ts` - List of recordings with status
-
- `admin-classes.ts` - Admin class management interface
-
-
### Navigation Updates
-
- Remove transcript page links
-
- Add classes link (make it home)
-
- Update auth redirect after signup to `/classes`
-
-
## Migration Strategy
-
-
**Breaking change**: Reset database schema to consolidate all migrations.
-
-
1. Export any critical production data (if needed)
-
2. Drop all tables
-
3. Consolidate migrations in `src/db/schema.ts`:
-
- Include all previous migrations
-
- Add new class system tables
-
- Add new columns to transcripts
-
4. Restart with version 1
-
5. Existing transcripts will be lost (acceptable for this phase)
-
-
## Admin UI Updates
-
-
### Class Management Tab
-
- Create new class form:
-
- Course code
-
- Course name
-
- Professor
-
- Semester dropdown (Fall/Spring/Summer/Winter)
-
- Year input
-
- List all classes (with archive status)
-
- Archive/unarchive button per class
-
- Delete class button
-
-
### Enrollment Management
-
- Search for class
-
- Add student by email
-
- Remove enrolled students
-
- View enrollment list per class
-
- Future: Bulk CSV import
-
-
### Recording Selection
-
- View pending recordings per class
-
- Select recording to transcribe for each meeting
-
- View transcription status
-
- Handle failed transcriptions
-
-
## Empty States
-
-
- **No classes**: Show only register card with message "No classes yet"
-
- **No recordings in class**: Show message "No recordings yet" with upload button
-
- **No pending recordings**: Show message in admin "All recordings processed"
-
-
## Future Enhancements (Out of Scope)
-
-
- Share/enrollment links for self-enrollment
-
- Notifications when transcripts ready
-
- Auto-transcribe settings per class
-
- Student/instructor roles
-
- Search/filter classes
-
- Bulk enrollment via CSV
-
- Meeting time templates (MWF, TTh patterns)
-
- Download all transcripts for a class
-
-
## Open Questions
-
-
None - spec is complete for initial implementation.
-
-
## Implementation Phases
-
-
### Phase 1: Database & Backend
-
1. Consolidate migrations and add new schema
-
2. Add API endpoints for classes and members
-
3. Update permissions middleware
-
4. Add admin endpoints
-
-
### Phase 2: Admin UI
-
1. Class management interface
-
2. Enrollment management
-
3. Recording selection interface
-
-
### Phase 3: Student UI
-
1. Classes page with cards
-
2. Individual class pages
-
3. Recording upload
-
4. Update navigation
-
-
### Phase 4: Testing & Polish
-
1. Test permissions thoroughly
-
2. Test archive behavior
-
3. Empty states
-
4. Error handling
+399
index.html
···
+
<!-- vim: setlocal noexpandtab nowrap: -->
+
<!doctype html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>C:\KIERANK.EXE - kierank.hackclub.app</title>
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
+
<style>
+
@import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap");
+
+
body {
+
background: #008080;
+
font-family: "Courier Prime", "Courier New", monospace;
+
color: #000000;
+
margin: 0;
+
padding: 20px;
+
line-height: 1.4;
+
font-size: 14px;
+
}
+
+
.hidden {
+
display: none !important;
+
}
+
+
.terminal {
+
background: #ffffff;
+
border: 2px solid #808080;
+
box-shadow:
+
inset -2px -2px #c0c0c0,
+
inset 2px 2px #404040,
+
4px 4px 0px #000000;
+
max-width: 750px;
+
margin: 0 auto;
+
padding: 0;
+
}
+
+
.title-bar {
+
background: linear-gradient(90deg, #000080, #1084d0);
+
color: #ffffff;
+
padding: 4px 8px;
+
font-weight: bold;
+
border-bottom: 1px solid #808080;
+
font-size: 12px;
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
}
+
+
.title-buttons {
+
display: flex;
+
gap: 2px;
+
}
+
+
.title-btn {
+
width: 16px;
+
height: 14px;
+
background: #c0c0c0;
+
border: 1px outset #ffffff;
+
font-size: 10px;
+
line-height: 12px;
+
text-align: center;
+
cursor: pointer;
+
}
+
+
.content {
+
padding: 15px;
+
background: #ffffff;
+
}
+
+
.prompt {
+
color: #000000;
+
margin: 0 0 10px 0;
+
}
+
+
.cursor {
+
background: #000000;
+
color: #ffffff;
+
animation: blink 1s infinite;
+
}
+
+
@keyframes blink {
+
+
0%,
+
50% {
+
opacity: 1;
+
}
+
+
51%,
+
100% {
+
opacity: 0;
+
}
+
}
+
+
/* Markdown rendered content styles */
+
#readme-content h3 {
+
color: #000080;
+
font-size: 20px;
+
margin: 15px 0 10px 0;
+
border-bottom: 2px solid #c0c0c0;
+
padding-bottom: 5px;
+
}
+
+
#readme-content h4 {
+
color: #000000;
+
font-size: 14px;
+
margin: 20px 0 8px 0;
+
text-transform: uppercase;
+
background: #c0c0c0;
+
padding: 4px 8px;
+
display: inline-block;
+
}
+
+
#readme-content p {
+
margin: 8px 0;
+
}
+
+
#readme-content ul {
+
margin: 8px 0;
+
padding-left: 20px;
+
list-style: none;
+
}
+
+
#readme-content ul li {
+
margin: 6px 0;
+
position: relative;
+
}
+
+
#readme-content ul li::before {
+
content: "► ";
+
color: #000080;
+
}
+
+
#readme-content a {
+
color: #000080;
+
text-decoration: underline;
+
}
+
+
#readme-content a:hover {
+
color: #0000ff;
+
background: #ffffcc;
+
}
+
+
#readme-content code {
+
background: #000080;
+
color: #ffffff;
+
padding: 1px 4px;
+
font-family: "Courier Prime", "Courier New", monospace;
+
}
+
+
#readme-content pre {
+
background: #000080;
+
color: #cbcbcb;
+
padding: 12px;
+
border: 2px inset #404040;
+
overflow-x: auto;
+
font-size: 12px;
+
line-height: 1.3;
+
}
+
+
#readme-content pre code {
+
background: transparent;
+
color: inherit;
+
padding: 0;
+
}
+
+
#readme-content strong {
+
color: #333333;
+
}
+
+
#readme-content em {
+
color: #808080;
+
font-style: italic;
+
}
+
+
.status-bar {
+
background: #c0c0c0;
+
color: #000000;
+
padding: 3px 8px;
+
border-top: 2px groove #ffffff;
+
font-size: 11px;
+
display: flex;
+
justify-content: space-between;
+
}
+
+
.separator {
+
color: #808080;
+
margin: 15px 0;
+
text-align: center;
+
}
+
+
.loading {
+
text-align: center;
+
padding: 20px;
+
color: #808080;
+
}
+
+
.loading::after {
+
content: "";
+
animation: dots 1.5s steps(4, end) infinite;
+
}
+
+
@keyframes dots {
+
+
0%,
+
20% {
+
content: "";
+
}
+
+
40% {
+
content: ".";
+
}
+
+
60% {
+
content: "..";
+
}
+
+
80%,
+
100% {
+
content: "...";
+
}
+
}
+
+
.error-box {
+
background: #ff0000;
+
color: #ffffff;
+
padding: 10px;
+
border: 2px inset #800000;
+
margin: 10px 0;
+
}
+
+
.ascii-header {
+
color: #000080;
+
font-size: 10px;
+
line-height: 1.1;
+
white-space: pre;
+
margin: 10px 0 15px 0;
+
text-align: center;
+
}
+
</style>
+
</head>
+
+
<body>
+
<div class="terminal">
+
<div class="title-bar">
+
<span>C:\KIERANK.EXE - kierank.hackclub.app</span>
+
<div class="title-buttons">
+
<div class="title-btn">_</div>
+
<div class="title-btn">□</div>
+
<div class="title-btn">×</div>
+
</div>
+
</div>
+
+
<div class="content">
+
<p class="prompt">
+
C:\KIERANK> README.EXE<span class="cursor"> </span>
+
</p>
+
+
<!-- biome-ignore format: ascii art -->
+
<pre class="ascii-header">
+
██╗ ██╗██╗███████╗██████╗ █████╗ ███╗ ██╗
+
██║ ██║██║██╔════╝██╔══██╗██╔══██╗████╗ ██║
+
█████╔╝██║█████╗ ██████╔╝███████║██╔██╗ ██║
+
██╔═██╗██║██╔══╝ ██╔══██╗██╔══██║██║╚██╗██║
+
██║ ██║██║███████╗██║ ██║██║ ██║██║ ╚████║
+
╚═╝ ╚═╝╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝
+
</pre>
+
+
<div class="separator">
+
════════════════════════════════════════════════════
+
</div>
+
+
<div id="readme-content">
+
<div class="loading" style="display: none">Loading README from GitHub</div>
+
+
<noscript>
+
<div class="error-box">
+
JavaScript is disabled so not rendering markdown.
+
</div>
+
<p>
+
View the README on GitHub:
+
<a href="https://github.com/taciturnaxolotl/taciturnaxolotl/blob/main/README.md">
+
taciturnaxolotl/README.md
+
</a>
+
</p>
+
</noscript>
+
</div>
+
+
<div class="separator">
+
════════════════════════════════════════════════════
+
</div>
+
+
<p style="font-size: 11px; color: #808080; text-align: center">
+
Hosted on <a href="https://hackclub.com/nest/">Hack Club Nest</a> |
+
<a href="https://github.com/taciturnaxolotl">GitHub</a> |
+
<a href="https://dunkirk.sh">dunkirk.sh</a>
+
</p>
+
</div>
+
+
<div class="status-bar">
+
<span id="status">Fetching README...</span>
+
<span id="time"></span>
+
</div>
+
</div>
+
+
<script>
+
const README_URL =
+
"https://raw.githubusercontent.com/taciturnaxolotl/taciturnaxolotl/refs/heads/main/README.md"
+
+
async function loadReadme() {
+
const contentDiv = document.getElementById("readme-content");
+
const statusSpan = document.getElementById("status");
+
const loadingEl = contentDiv.querySelector(".loading");
+
+
// Show loading
+
if (loadingEl) loadingEl.classList.remove("hidden");
+
+
+
try {
+
const response = await fetch(README_URL);
+
if (!response.ok) {
+
throw new Error(`HTTP ${response.status}`);
+
}
+
+
const markdown = await response.text();
+
+
// Configure marked for GFM
+
marked.setOptions({
+
gfm: true,
+
breaks: true,
+
});
+
+
contentDiv.innerHTML = marked.parse(markdown);
+
+
// Strip emojis from headers
+
contentDiv.querySelectorAll("h3, h4").forEach((header) => {
+
const text = header.textContent;
+
+
// Regex targets:
+
// - \p{Extended_Pictographic}: most modern emoji/pictographs
+
// - \p{Emoji_Presentation}: characters default-rendered as emoji
+
// - Variation Selector-16 (U+FE0F): emoji presentation modifier
+
// - Common symbol blocks that often carry emoji-like glyphs
+
const emojiRegex =
+
/[\p{Extended_Pictographic}\p{Emoji_Presentation}\uFE0F]|[\u2600-\u26FF\u2700-\u27BF]/gu;
+
+
// Strip emojis
+
let cleaned = text.replace(emojiRegex, "");
+
+
// Collapse multiple whitespace into single spaces
+
cleaned = cleaned.replace(/\s+/g, " ");
+
+
// Remove spaces before punctuation (e.g., "Hello !" -> "Hello!")
+
cleaned = cleaned.replace(/\s+([!?,.;:])/g, "$1");
+
+
// Trim leading/trailing whitespace
+
cleaned = cleaned.trim();
+
+
// Replace header content safely
+
header.textContent = cleaned;
+
});
+
+
statusSpan.textContent = "README loaded successfully";
+
} catch (error) {
+
contentDiv.innerHTML = `
+
<div class="error-box">
+
ERROR: Failed to load README<br>
+
${error.message}
+
</div>
+
<p>Try refreshing the page or visit
+
<a href="https://github.com/taciturnaxolotl">github.com/taciturnaxolotl</a> directly.</p>
+
`;
+
statusSpan.textContent = "Error loading README";
+
}
+
}
+
+
function updateTime() {
+
const now = new Date();
+
const timeStr = now.toLocaleTimeString("en-US", {
+
hour: "2-digit",
+
minute: "2-digit",
+
hour12: true,
+
});
+
document.getElementById("time").textContent = timeStr;
+
}
+
+
// Initialize
+
updateTime();
+
setInterval(updateTime, 1000);
+
loadReadme();
+
</script>
+
</body>
+
+
</html>
+
+
</html>
+
+
</html>
+70
scripts/send-test-emails.ts
···
+
/**
+
* Send test emails to preview all email templates
+
* Usage: bun scripts/send-test-emails.ts <email>
+
*/
+
+
import { sendEmail } from "../src/lib/email";
+
import {
+
verifyEmailTemplate,
+
passwordResetTemplate,
+
transcriptionCompleteTemplate,
+
} from "../src/lib/email-templates";
+
+
const targetEmail = process.argv[2];
+
+
if (!targetEmail) {
+
console.error("Usage: bun scripts/send-test-emails.ts <email>");
+
process.exit(1);
+
}
+
+
async function sendTestEmails() {
+
console.log(`Sending test emails to ${targetEmail}...`);
+
+
try {
+
// 1. Email verification
+
console.log("\n[1/3] Sending email verification...");
+
await sendEmail({
+
to: targetEmail,
+
subject: "Test: Verify your email - Thistle",
+
html: verifyEmailTemplate({
+
name: "Test User",
+
code: "123456",
+
token: "test-token-abc123",
+
}),
+
});
+
console.log("✓ Email verification sent");
+
+
// 2. Password reset
+
console.log("\n[2/3] Sending password reset...");
+
await sendEmail({
+
to: targetEmail,
+
subject: "Test: Reset your password - Thistle",
+
html: passwordResetTemplate({
+
name: "Test User",
+
resetLink: "https://thistle.app/reset-password?token=test-token-xyz789",
+
}),
+
});
+
console.log("✓ Password reset sent");
+
+
// 3. Transcription complete
+
console.log("\n[3/3] Sending transcription complete...");
+
await sendEmail({
+
to: targetEmail,
+
subject: "Test: Transcription complete - Thistle",
+
html: transcriptionCompleteTemplate({
+
name: "Test User",
+
originalFilename: "lecture-2024-11-22.m4a",
+
transcriptLink: "https://thistle.app/transcriptions/123",
+
className: "Introduction to Computer Science",
+
}),
+
});
+
console.log("✓ Transcription complete sent");
+
+
console.log("\n✅ All test emails sent successfully!");
+
} catch (error) {
+
console.error("\n❌ Error sending emails:", error);
+
process.exit(1);
+
}
+
}
+
+
sendTestEmails();
+144 -4
src/components/auth.ts
···
@state() needsRegistration = false;
@state() passwordStrength: PasswordStrengthResult | null = null;
@state() passkeySupported = false;
+
@state() needsEmailVerification = false;
+
@state() verificationCode = "";
static override styles = css`
:host {
···
.info-text {
color: var(--text);
font-size: 0.875rem;
-
margin: 0;
+
margin: 0 0 1.5rem 0;
+
line-height: 1.5;
+
}
+
+
.verification-code-input {
+
text-align: center;
+
font-size: 1.5rem;
+
letter-spacing: 0.5rem;
+
font-weight: 600;
+
padding: 1rem;
+
font-family: 'Monaco', 'Courier New', monospace;
+
}
+
+
.btn-secondary {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
flex: 1;
+
}
+
+
.btn-secondary:hover:not(:disabled) {
+
border-color: var(--primary);
+
color: var(--primary);
}
.divider {
···
return;
}
-
this.user = await response.json();
+
const data = await response.json();
+
+
if (data.email_verification_required) {
+
this.needsEmailVerification = true;
+
this.password = "";
+
this.error = "";
+
return;
+
}
+
+
this.user = data;
this.closeModal();
await this.checkAuth();
window.dispatchEvent(new CustomEvent("auth-changed"));
···
return;
}
-
this.user = await response.json();
+
const data = await response.json();
+
+
if (data.email_verification_required) {
+
this.needsEmailVerification = true;
+
this.password = "";
+
this.error = "";
+
return;
+
}
+
+
this.user = data;
this.closeModal();
await this.checkAuth();
window.dispatchEvent(new CustomEvent("auth-changed"));
···
this.password = (e.target as HTMLInputElement).value;
}
+
private handleVerificationCodeInput(e: Event) {
+
this.verificationCode = (e.target as HTMLInputElement).value;
+
}
+
+
private async handleVerifyEmail(e: Event) {
+
e.preventDefault();
+
this.error = "";
+
this.isSubmitting = true;
+
+
try {
+
const response = await fetch("/api/auth/verify-email", {
+
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
},
+
body: JSON.stringify({
+
email: this.email,
+
code: this.verificationCode,
+
}),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Verification failed";
+
return;
+
}
+
+
// Successfully verified - redirect to classes
+
this.closeModal();
+
await this.checkAuth();
+
window.dispatchEvent(new CustomEvent("auth-changed"));
+
window.location.href = "/classes";
+
} catch (error) {
+
this.error = error instanceof Error ? error.message : "An error occurred";
+
} finally {
+
this.isSubmitting = false;
+
}
+
}
+
private handlePasswordBlur() {
if (!this.needsRegistration) return;
···
<div class="modal-overlay" @click=${this.closeModal}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<h2 class="modal-title">
-
${this.needsRegistration ? "Create Account" : "Sign In"}
+
${this.needsEmailVerification ? "Verify Email" : this.needsRegistration ? "Create Account" : "Sign In"}
</h2>
${
+
this.needsEmailVerification
+
? html`
+
<p class="info-text">
+
We sent a 6-digit verification code to <strong>${this.email}</strong>.<br>
+
Check your email and enter the code below.
+
</p>
+
+
<form @submit=${this.handleVerifyEmail}>
+
<div class="form-group">
+
<label for="verification-code">Verification Code</label>
+
<input
+
type="text"
+
id="verification-code"
+
class="verification-code-input"
+
placeholder="000000"
+
.value=${this.verificationCode}
+
@input=${this.handleVerificationCodeInput}
+
required
+
maxlength="6"
+
pattern="[0-9]{6}"
+
inputmode="numeric"
+
?disabled=${this.isSubmitting}
+
autocomplete="one-time-code"
+
/>
+
</div>
+
+
${
+
this.error
+
? html`<div class="error-message">${this.error}</div>`
+
: ""
+
}
+
+
<div class="modal-actions">
+
<button
+
type="submit"
+
class="btn-primary"
+
?disabled=${this.isSubmitting || this.verificationCode.length !== 6}
+
>
+
${this.isSubmitting ? "Verifying..." : "Verify Email"}
+
</button>
+
<button
+
type="button"
+
class="btn-secondary"
+
@click=${() => {
+
this.needsEmailVerification = false;
+
this.verificationCode = "";
+
this.error = "";
+
}}
+
?disabled=${this.isSubmitting}
+
>
+
Back
+
</button>
+
</div>
+
</form>
+
`
+
: html`
+
${
this.needsRegistration
? html`
<p class="info-text">
···
</button>
</div>
</form>
+
`
+
}
</div>
</div>
`
+34
src/db/schema.ts
···
VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now'));
`,
},
+
{
+
version: 8,
+
name: "Add email verification system",
+
sql: `
+
-- Add email verification flag to users
+
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0;
+
+
-- Email verification tokens table
+
CREATE TABLE IF NOT EXISTS email_verification_tokens (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
token TEXT NOT NULL UNIQUE,
+
expires_at INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_verification_tokens_user_id ON email_verification_tokens(user_id);
+
CREATE INDEX IF NOT EXISTS idx_verification_tokens_token ON email_verification_tokens(token);
+
+
-- Password reset tokens table
+
CREATE TABLE IF NOT EXISTS password_reset_tokens (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
token TEXT NOT NULL UNIQUE,
+
expires_at INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
+
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
+
`,
+
},
];
function getCurrentVersion(): number {
+299 -12
src/index.ts
···
updateUserName,
updateUserPassword,
updateUserRole,
+
createEmailVerificationToken,
+
verifyEmailToken,
+
verifyEmailCode,
+
isEmailVerified,
+
createPasswordResetToken,
+
verifyPasswordResetToken,
+
consumePasswordResetToken,
} from "./lib/auth";
import {
addToWaitlist,
···
verifyAndAuthenticatePasskey,
verifyAndCreatePasskey,
} from "./lib/passkey";
-
import { enforceRateLimit } from "./lib/rate-limit";
+
import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
import { getTranscriptVTT } from "./lib/transcript-storage";
import {
MAX_FILE_SIZE,
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
+
import { sendEmail } from "./lib/email";
+
import {
+
verifyEmailTemplate,
+
passwordResetTemplate,
+
} from "./lib/email-templates";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
import classesHTML from "./pages/classes.html";
import indexHTML from "./pages/index.html";
+
import resetPasswordHTML from "./pages/reset-password.html";
import settingsHTML from "./pages/settings.html";
import transcribeHTML from "./pages/transcribe.html";
···
"/admin": adminHTML,
"/checkout": checkoutHTML,
"/settings": settingsHTML,
+
"/reset-password": resetPasswordHTML,
"/transcribe": transcribeHTML,
"/classes": classesHTML,
"/classes/*": classHTML,
···
try {
// Rate limiting
const rateLimitError = enforceRateLimit(req, "register", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
+
ip: { max: 5, windowSeconds: 30 * 60 },
});
if (rateLimitError) return rateLimitError;
···
}
const user = await createUser(email, password, name);
-
// Attempt to sync existing Polar subscriptions
+
// Send verification email - MUST succeed for registration to complete
+
const { code, token } = createEmailVerificationToken(user.id);
+
+
try {
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email - Thistle",
+
html: verifyEmailTemplate({
+
name: user.name,
+
code,
+
token,
+
}),
+
});
+
} catch (err) {
+
console.error("[Email] Failed to send verification email:", err);
+
// Rollback user creation - direct DB delete since user was just created
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]);
+
db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
+
db.run("DELETE FROM users WHERE id = ?", [user.id]);
+
return Response.json(
+
{ error: "Failed to send verification email. Please try again later." },
+
{ status: 500 },
+
);
+
}
+
+
// Attempt to sync existing Polar subscriptions (after email succeeds)
syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
// Silent fail - don't block registration
});
+
// Clear rate limits on successful registration
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
-
const userAgent = req.headers.get("user-agent") ?? "unknown";
-
const sessionId = createSession(user.id, ipAddress, userAgent);
+
clearRateLimit("register", email, ipAddress);
+
+
// Return success but indicate email verification is needed
+
// Don't create session yet - they need to verify first
return Response.json(
-
{ user: { id: user.id, email: user.email } },
-
{
-
headers: {
-
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
-
},
+
{
+
user: { id: user.id, email: user.email },
+
email_verification_required: true,
},
+
{ status: 200 },
);
} catch (err: unknown) {
const error = err as { message?: string };
···
// Rate limiting: Per IP and per account
const rateLimitError = enforceRateLimit(req, "login", {
-
ip: { max: 10, windowSeconds: 15 * 60 },
-
account: { max: 5, windowSeconds: 15 * 60, email },
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
account: { max: 5, windowSeconds: 5 * 60, email },
});
if (rateLimitError) return rateLimitError;
···
{ status: 401 },
);
}
+
+
// Clear rate limits on successful authentication
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
+
clearRateLimit("login", email, ipAddress);
+
+
// Check if email is verified
+
if (!isEmailVerified(user.id)) {
+
return Response.json(
+
{
+
user: { id: user.id, email: user.email },
+
email_verification_required: true,
+
},
+
{ status: 200 },
+
);
+
}
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
···
}
},
},
+
"/api/auth/verify-email": {
+
GET: async (req) => {
+
try {
+
const url = new URL(req.url);
+
const token = url.searchParams.get("token");
+
+
if (!token) {
+
return Response.redirect("/", 302);
+
}
+
+
const result = verifyEmailToken(token);
+
+
if (!result) {
+
return Response.redirect("/", 302);
+
}
+
+
// Create session for the verified user
+
const ipAddress =
+
req.headers.get("x-forwarded-for") ??
+
req.headers.get("x-real-ip") ??
+
"unknown";
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
+
const sessionId = createSession(result.userId, ipAddress, userAgent);
+
+
// Redirect to classes with session cookie
+
return new Response(null, {
+
status: 302,
+
headers: {
+
"Location": "/classes",
+
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
+
},
+
});
+
} catch (error) {
+
console.error("[Email] Verification error:", error);
+
return Response.redirect("/", 302);
+
}
+
},
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { email, code } = body;
+
+
if (!email || !code) {
+
return Response.json(
+
{ error: "Email and verification code required" },
+
{ status: 400 },
+
);
+
}
+
+
// Get user by email
+
const user = getUserByEmail(email);
+
if (!user) {
+
return Response.json(
+
{ error: "User not found" },
+
{ status: 404 },
+
);
+
}
+
+
// Check if already verified
+
if (isEmailVerified(user.id)) {
+
return Response.json(
+
{ error: "Email already verified" },
+
{ status: 400 },
+
);
+
}
+
+
const success = verifyEmailCode(user.id, code);
+
+
if (!success) {
+
return Response.json(
+
{ error: "Invalid or expired verification code" },
+
{ status: 400 },
+
);
+
}
+
+
// Create session after successful verification
+
const ipAddress =
+
req.headers.get("x-forwarded-for") ??
+
req.headers.get("x-real-ip") ??
+
"unknown";
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
+
const sessionId = createSession(user.id, ipAddress, userAgent);
+
+
return Response.json(
+
{
+
message: "Email verified successfully",
+
email_verified: true,
+
user: { id: user.id, email: user.email },
+
},
+
{
+
headers: {
+
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
+
},
+
},
+
);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/auth/resend-verification": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "resend-verification", {
+
account: { max: 3, windowSeconds: 60 * 60, email: user.email },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
// Check if already verified
+
if (isEmailVerified(user.id)) {
+
return Response.json(
+
{ error: "Email already verified" },
+
{ status: 400 },
+
);
+
}
+
+
// Generate new code and send email
+
const { code, token } = createEmailVerificationToken(user.id);
+
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email - Thistle",
+
html: verifyEmailTemplate({
+
name: user.name,
+
code,
+
token,
+
}),
+
});
+
+
return Response.json({ message: "Verification email sent" });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/auth/forgot-password": {
+
POST: async (req) => {
+
try {
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "forgot-password", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
+
const body = await req.json();
+
const { email } = body;
+
+
if (!email) {
+
return Response.json({ error: "Email required" }, { status: 400 });
+
}
+
+
// Always return success to prevent email enumeration
+
const user = getUserByEmail(email);
+
if (user) {
+
const origin =
+
req.headers.get("origin") || "http://localhost:3000";
+
const resetToken = createPasswordResetToken(user.id);
+
const resetLink = `${origin}/reset-password?token=${resetToken}`;
+
+
await sendEmail({
+
to: user.email,
+
subject: "Reset your password - Thistle",
+
html: passwordResetTemplate({
+
name: user.name,
+
resetLink,
+
}),
+
}).catch((err) => {
+
console.error("[Email] Failed to send password reset:", err);
+
});
+
}
+
+
return Response.json({
+
message:
+
"If an account exists with that email, a password reset link has been sent",
+
});
+
} catch (error) {
+
console.error("[Email] Forgot password error:", error);
+
return Response.json(
+
{ error: "Failed to process request" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/auth/reset-password": {
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { token, password } = body;
+
+
if (!token || !password) {
+
return Response.json(
+
{ error: "Token and password required" },
+
{ status: 400 },
+
);
+
}
+
+
// Validate password format (client-side hashed PBKDF2)
+
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
return Response.json(
+
{ error: "Invalid password format" },
+
{ status: 400 },
+
);
+
}
+
+
const userId = verifyPasswordResetToken(token);
+
if (!userId) {
+
return Response.json(
+
{ error: "Invalid or expired reset token" },
+
{ status: 400 },
+
);
+
}
+
+
// Update password and consume token
+
await updateUserPassword(userId, password);
+
consumePasswordResetToken(token);
+
+
return Response.json({ message: "Password reset successfully" });
+
} catch (error) {
+
console.error("[Email] Reset password error:", error);
+
return Response.json(
+
{ error: "Failed to reset password" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
"/api/auth/logout": {
POST: async (req) => {
const sessionId = getSessionFromRequest(req);
···
created_at: user.created_at,
role: user.role,
has_subscription: !!subscription,
+
email_verified: isEmailVerified(user.id),
});
},
},
+131
src/lib/auth.ts
···
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
}
+
/**
+
* Email verification functions
+
*/
+
+
export function createEmailVerificationToken(userId: number): { code: string; token: string } {
+
// Generate a 6-digit code for user to enter
+
const code = Math.floor(100000 + Math.random() * 900000).toString();
+
const id = crypto.randomUUID();
+
const token = crypto.randomUUID(); // Separate token for URL
+
const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
+
+
// Delete any existing tokens for this user
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
+
+
// Store the code as the token field (for manual entry)
+
db.run(
+
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
+
[id, userId, code, expiresAt],
+
);
+
+
// Store the URL token as a separate entry
+
db.run(
+
"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
+
[crypto.randomUUID(), userId, token, expiresAt],
+
);
+
+
return { code, token };
+
}
+
+
export function verifyEmailToken(
+
token: string,
+
): { userId: number; email: string } | null {
+
const now = Math.floor(Date.now() / 1000);
+
+
const result = db
+
.query<
+
{ user_id: number; email: string },
+
[string, number]
+
>(
+
`SELECT evt.user_id, u.email
+
FROM email_verification_tokens evt
+
JOIN users u ON evt.user_id = u.id
+
WHERE evt.token = ? AND evt.expires_at > ?`,
+
)
+
.get(token, now);
+
+
if (!result) return null;
+
+
// Mark email as verified
+
db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [result.user_id]);
+
+
// Delete the token (one-time use)
+
db.run("DELETE FROM email_verification_tokens WHERE token = ?", [token]);
+
+
return { userId: result.user_id, email: result.email };
+
}
+
+
export function verifyEmailCode(
+
userId: number,
+
code: string,
+
): boolean {
+
const now = Math.floor(Date.now() / 1000);
+
+
const result = db
+
.query<
+
{ user_id: number },
+
[number, string, number]
+
>(
+
`SELECT user_id
+
FROM email_verification_tokens
+
WHERE user_id = ? AND token = ? AND expires_at > ?`,
+
)
+
.get(userId, code, now);
+
+
if (!result) return false;
+
+
// Mark email as verified
+
db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
+
+
// Delete the token (one-time use)
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
+
+
return true;
+
}
+
+
export function isEmailVerified(userId: number): boolean {
+
const result = db
+
.query<{ email_verified: number }, [number]>(
+
"SELECT email_verified FROM users WHERE id = ?",
+
)
+
.get(userId);
+
+
return result?.email_verified === 1;
+
}
+
+
/**
+
* Password reset functions
+
*/
+
+
export function createPasswordResetToken(userId: number): string {
+
const token = crypto.randomUUID();
+
const id = crypto.randomUUID();
+
const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
+
+
// Delete any existing tokens for this user
+
db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]);
+
+
db.run(
+
"INSERT INTO password_reset_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
+
[id, userId, token, expiresAt],
+
);
+
+
return token;
+
}
+
+
export function verifyPasswordResetToken(token: string): number | null {
+
const now = Math.floor(Date.now() / 1000);
+
+
const result = db
+
.query<{ user_id: number }, [string, number]>(
+
"SELECT user_id FROM password_reset_tokens WHERE token = ? AND expires_at > ?",
+
)
+
.get(token, now);
+
+
return result?.user_id ?? null;
+
}
+
+
export function consumePasswordResetToken(token: string): void {
+
db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
+
}
+
export function isUserAdmin(userId: number): boolean {
const result = db
.query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
+233
src/lib/email-templates.ts
···
+
/**
+
* Email templates for transactional emails
+
* Uses inline CSS for maximum email client compatibility
+
*/
+
+
const baseStyles = `
+
body {
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+
line-height: 1.6;
+
color: #2d3142;
+
background-color: #ffffff;
+
margin: 0;
+
padding: 0;
+
}
+
.container {
+
max-width: 600px;
+
margin: 0 auto;
+
padding: 2rem 1rem;
+
}
+
.header {
+
text-align: center;
+
margin-bottom: 2rem;
+
}
+
.header h1 {
+
color: #2d3142;
+
font-size: 1.5rem;
+
margin: 0;
+
}
+
.content {
+
background: #ffffff;
+
padding: 2rem;
+
border-radius: 0.5rem;
+
border: 1px solid #bfc0c0;
+
}
+
.button {
+
display: inline-block;
+
background-color: #ef8354;
+
color: #ffffff;
+
text-decoration: none;
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-weight: 500;
+
font-size: 1rem;
+
margin: 1rem 0;
+
border: 2px solid #ef8354;
+
}
+
.footer {
+
text-align: center;
+
margin-top: 2rem;
+
color: #4f5d75;
+
font-size: 0.875rem;
+
}
+
.code {
+
background: #f5f5f5;
+
border: 1px solid #bfc0c0;
+
border-radius: 0.25rem;
+
padding: 0.5rem 1rem;
+
font-family: 'Courier New', monospace;
+
font-size: 1.5rem;
+
letter-spacing: 0.25rem;
+
text-align: center;
+
margin: 1rem 0;
+
}
+
.info-box {
+
background: #f5f5f5;
+
border: 1px solid #bfc0c0;
+
border-radius: 6px;
+
padding: 1.25rem;
+
margin: 1rem 0;
+
}
+
.info-box-label {
+
color: #4f5d75;
+
font-size: 0.75rem;
+
text-transform: uppercase;
+
letter-spacing: 0.05rem;
+
margin: 0 0 0.25rem 0;
+
font-weight: 600;
+
}
+
.info-box-value {
+
color: #2d3142;
+
font-size: 1rem;
+
margin: 0;
+
}
+
.info-box-divider {
+
border: 0;
+
border-top: 1px solid #bfc0c0;
+
margin: 1rem 0;
+
}
+
`;
+
+
interface VerifyEmailOptions {
+
name: string | null;
+
code: string;
+
token: string;
+
}
+
+
export function verifyEmailTemplate(options: VerifyEmailOptions): string {
+
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
const domain = process.env.DOMAIN || "https://thistle.app";
+
const verifyLink = `${domain}/api/auth/verify-email?token=${options.token}`;
+
+
return `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Verify Your Email - Thistle</title>
+
<style>${baseStyles}</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="header">
+
<h1>🪻 Thistle</h1>
+
</div>
+
<div class="content">
+
<h2>${greeting}!</h2>
+
<p>Thanks for signing up for Thistle. Please verify your email address to get started.</p>
+
<p><strong>Your verification code is:</strong></p>
+
<div class="code">${options.code}</div>
+
<p style="color: #4f5d75; font-size: 0.875rem; margin-top: 1rem; margin-bottom: 0.75rem;">
+
This code will expire in 24 hours. Enter it in the verification dialog after you login, or click the button below:
+
</p>
+
<p style="text-align: center; margin-top: 0; margin-bottom: 0.75rem;">
+
<a href="${verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email</a>
+
</p>
+
</div>
+
<div class="footer">
+
<p>If you didn't create an account, you can safely ignore this email.</p>
+
</div>
+
</div>
+
</body>
+
</html>
+
`.trim();
+
}
+
+
interface PasswordResetOptions {
+
name: string | null;
+
resetLink: string;
+
}
+
+
export function passwordResetTemplate(options: PasswordResetOptions): string {
+
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
+
return `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Reset Your Password - Thistle</title>
+
<style>${baseStyles}</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="header">
+
<h1>🪻 Thistle</h1>
+
</div>
+
<div class="content">
+
<h2>${greeting}!</h2>
+
<p>We received a request to reset your password. Click the button below to create a new password.</p>
+
<p style="text-align: center;">
+
<a href="${options.resetLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 1rem 0; border: 2px solid #ef8354;">Reset Password</a>
+
</p>
+
<p style="color: #4f5d75; font-size: 0.875rem;">
+
If the button doesn't work, copy and paste this link into your browser:<br>
+
<a href="${options.resetLink}" style="color: #4f5d75; word-break: break-all;">${options.resetLink}</a>
+
</p>
+
<p style="color: #4f5d75; font-size: 0.875rem;">
+
This link will expire in 1 hour.
+
</p>
+
</div>
+
<div class="footer">
+
<p>If you didn't request a password reset, you can safely ignore this email.</p>
+
</div>
+
</div>
+
</body>
+
</html>
+
`.trim();
+
}
+
+
interface TranscriptionCompleteOptions {
+
name: string | null;
+
originalFilename: string;
+
transcriptLink: string;
+
className?: string;
+
}
+
+
export function transcriptionCompleteTemplate(
+
options: TranscriptionCompleteOptions,
+
): string {
+
const greeting = options.name ? `Hi ${options.name}` : "Hi there";
+
+
return `
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Transcription Complete - Thistle</title>
+
<style>${baseStyles}</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="header">
+
<h1>🪻 Thistle</h1>
+
</div>
+
<div class="content">
+
<h2>${greeting}!</h2>
+
<p>Your transcription is ready!</p>
+
+
<div class="info-box">
+
${options.className ? `
+
<p class="info-box-label">Class</p>
+
<p class="info-box-value">${options.className}</p>
+
<hr class="info-box-divider">
+
` : ''}
+
<p class="info-box-label">File</p>
+
<p class="info-box-value">${options.originalFilename}</p>
+
</div>
+
+
<p style="text-align: center; margin-top: 1.5rem; margin-bottom: 0;">
+
<a href="${options.transcriptLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">View Transcript</a>
+
</p>
+
</div>
+
<div class="footer">
+
<p>Thanks for using Thistle!</p>
+
</div>
+
</div>
+
</body>
+
</html>
+
`.trim();
+
}
+160
src/lib/email-verification.test.ts
···
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+
import db from "../db/schema";
+
import {
+
createUser,
+
createEmailVerificationToken,
+
verifyEmailToken,
+
isEmailVerified,
+
createPasswordResetToken,
+
verifyPasswordResetToken,
+
consumePasswordResetToken,
+
} from "./auth";
+
+
describe("Email Verification", () => {
+
let userId: number;
+
const testEmail = `test-verify-${Date.now()}@example.com`;
+
+
beforeEach(async () => {
+
// Create test user
+
const user = await createUser(testEmail, "a".repeat(64), "Test User");
+
userId = user.id;
+
});
+
+
afterEach(() => {
+
// Cleanup
+
db.run("DELETE FROM users WHERE email = ?", [testEmail]);
+
db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [
+
userId,
+
]);
+
});
+
+
test("creates verification token", () => {
+
const token = createEmailVerificationToken(userId);
+
expect(token).toBeDefined();
+
expect(typeof token).toBe("string");
+
expect(token.length).toBeGreaterThan(0);
+
});
+
+
test("verifies valid token", () => {
+
const token = createEmailVerificationToken(userId);
+
const result = verifyEmailToken(token);
+
+
expect(result).not.toBeNull();
+
expect(result?.userId).toBe(userId);
+
expect(result?.email).toBe(testEmail);
+
expect(isEmailVerified(userId)).toBe(true);
+
});
+
+
test("rejects invalid token", () => {
+
const result = verifyEmailToken("invalid-token-12345");
+
expect(result).toBeNull();
+
expect(isEmailVerified(userId)).toBe(false);
+
});
+
+
test("token is one-time use", () => {
+
const token = createEmailVerificationToken(userId);
+
+
// First use succeeds
+
const firstResult = verifyEmailToken(token);
+
expect(firstResult).not.toBeNull();
+
+
// Second use fails
+
const secondResult = verifyEmailToken(token);
+
expect(secondResult).toBeNull();
+
});
+
+
test("rejects expired token", () => {
+
const token = createEmailVerificationToken(userId);
+
+
// Manually expire the token
+
db.run(
+
"UPDATE email_verification_tokens SET expires_at = ? WHERE token = ?",
+
[Math.floor(Date.now() / 1000) - 100, token],
+
);
+
+
const result = verifyEmailToken(token);
+
expect(result).toBeNull();
+
});
+
+
test("replaces existing token when creating new one", () => {
+
const token1 = createEmailVerificationToken(userId);
+
const token2 = createEmailVerificationToken(userId);
+
+
// First token should be invalidated
+
expect(verifyEmailToken(token1)).toBeNull();
+
+
// Second token should work
+
expect(verifyEmailToken(token2)).not.toBeNull();
+
});
+
});
+
+
describe("Password Reset", () => {
+
let userId: number;
+
const testEmail = `test-reset-${Date.now()}@example.com`;
+
+
beforeEach(async () => {
+
const user = await createUser(testEmail, "a".repeat(64), "Test User");
+
userId = user.id;
+
});
+
+
afterEach(() => {
+
db.run("DELETE FROM users WHERE email = ?", [testEmail]);
+
db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]);
+
});
+
+
test("creates reset token", () => {
+
const token = createPasswordResetToken(userId);
+
expect(token).toBeDefined();
+
expect(typeof token).toBe("string");
+
expect(token.length).toBeGreaterThan(0);
+
});
+
+
test("verifies valid reset token", () => {
+
const token = createPasswordResetToken(userId);
+
const verifiedUserId = verifyPasswordResetToken(token);
+
+
expect(verifiedUserId).toBe(userId);
+
});
+
+
test("rejects invalid reset token", () => {
+
const verifiedUserId = verifyPasswordResetToken("invalid-token-12345");
+
expect(verifiedUserId).toBeNull();
+
});
+
+
test("consumes reset token", () => {
+
const token = createPasswordResetToken(userId);
+
+
// Token works before consumption
+
expect(verifyPasswordResetToken(token)).toBe(userId);
+
+
// Consume token
+
consumePasswordResetToken(token);
+
+
// Token no longer works
+
expect(verifyPasswordResetToken(token)).toBeNull();
+
});
+
+
test("rejects expired reset token", () => {
+
const token = createPasswordResetToken(userId);
+
+
// Manually expire the token
+
db.run(
+
"UPDATE password_reset_tokens SET expires_at = ? WHERE token = ?",
+
[Math.floor(Date.now() / 1000) - 100, token],
+
);
+
+
const verifiedUserId = verifyPasswordResetToken(token);
+
expect(verifiedUserId).toBeNull();
+
});
+
+
test("replaces existing reset token when creating new one", () => {
+
const token1 = createPasswordResetToken(userId);
+
const token2 = createPasswordResetToken(userId);
+
+
// First token should be invalidated
+
expect(verifyPasswordResetToken(token1)).toBeNull();
+
+
// Second token should work
+
expect(verifyPasswordResetToken(token2)).toBe(userId);
+
});
+
});
+100
src/lib/email.ts
···
+
/**
+
* Email service using MailChannels API
+
* Docs: https://api.mailchannels.net/tx/v1/documentation
+
*/
+
+
interface EmailAddress {
+
email: string;
+
name?: string;
+
}
+
+
interface EmailContent {
+
type: "text/plain" | "text/html";
+
value: string;
+
}
+
+
interface SendEmailOptions {
+
to: string | EmailAddress;
+
subject: string;
+
html?: string;
+
text?: string;
+
replyTo?: string;
+
}
+
+
/**
+
* Send an email via MailChannels
+
*/
+
export async function sendEmail(options: SendEmailOptions): Promise<void> {
+
const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app";
+
const fromName = process.env.SMTP_FROM_NAME || "Thistle";
+
const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app";
+
const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY;
+
const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY;
+
+
if (!dkimPrivateKey) {
+
throw new Error(
+
"DKIM_PRIVATE_KEY environment variable is required for sending emails",
+
);
+
}
+
+
if (!mailchannelsApiKey) {
+
throw new Error(
+
"MAILCHANNELS_API_KEY environment variable is required for sending emails",
+
);
+
}
+
+
// Normalize recipient
+
const recipient =
+
typeof options.to === "string" ? { email: options.to } : options.to;
+
+
// Build content array
+
const content: EmailContent[] = [];
+
if (options.text) {
+
content.push({ type: "text/plain", value: options.text });
+
}
+
if (options.html) {
+
content.push({ type: "text/html", value: options.html });
+
}
+
+
if (content.length === 0) {
+
throw new Error("At least one of 'text' or 'html' must be provided");
+
}
+
+
const payload = {
+
personalizations: [
+
{
+
to: [recipient],
+
...(options.replyTo && {
+
reply_to: { email: options.replyTo },
+
}),
+
dkim_domain: dkimDomain,
+
dkim_selector: "mailchannels",
+
dkim_private_key: dkimPrivateKey,
+
},
+
],
+
from: {
+
email: fromEmail,
+
name: fromName,
+
},
+
subject: options.subject,
+
content,
+
};
+
+
const response = await fetch("https://api.mailchannels.net/tx/v1/send", {
+
method: "POST",
+
headers: {
+
"content-type": "application/json",
+
"X-Api-Key": mailchannelsApiKey,
+
},
+
body: JSON.stringify(payload),
+
});
+
+
if (!response.ok) {
+
const errorText = await response.text();
+
throw new Error(
+
`MailChannels API error (${response.status}): ${errorText}`,
+
);
+
}
+
+
console.log(`[Email] Sent "${options.subject}" to ${recipient.email}`);
+
}
+18
src/lib/rate-limit.ts
···
return null; // Allowed
}
+
export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void {
+
// Clear account-based rate limits
+
if (email) {
+
db.run(
+
"DELETE FROM rate_limit_attempts WHERE key = ?",
+
[`${endpoint}:account:${email.toLowerCase()}`]
+
);
+
}
+
+
// Clear IP-based rate limits
+
if (ipAddress) {
+
db.run(
+
"DELETE FROM rate_limit_attempts WHERE key = ?",
+
[`${endpoint}:ip:${ipAddress}`]
+
);
+
}
+
}
+
export function cleanupOldAttempts(olderThanSeconds = 86400) {
// Clean up attempts older than specified time (default: 24 hours)
const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds;
+147
src/pages/reset-password.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Reset Password - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
</head>
+
+
<body>
+
<auth-component></auth-component>
+
+
<main>
+
<div class="container" style="max-width: 28rem; margin: 4rem auto; padding: 0 1rem;">
+
<div class="reset-card" style="background: var(--white); border: 1px solid var(--silver); border-radius: 0.5rem; padding: 2rem;">
+
<h1 style="margin-top: 0; text-align: center;">🪻 Reset Password</h1>
+
+
<div id="form-container">
+
<form id="reset-form">
+
<div style="margin-bottom: 1.5rem;">
+
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">New Password</label>
+
<input
+
type="password"
+
id="password"
+
required
+
minlength="8"
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
+
>
+
</div>
+
+
<div style="margin-bottom: 1.5rem;">
+
<label for="confirm-password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Confirm Password</label>
+
<input
+
type="password"
+
id="confirm-password"
+
required
+
minlength="8"
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
+
>
+
</div>
+
+
<div id="error-message" style="display: none; color: var(--coral); margin-bottom: 1rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.25rem;"></div>
+
+
<button
+
type="submit"
+
id="submit-btn"
+
style="width: 100%; padding: 0.75rem; background: var(--accent); color: var(--white); border: none; border-radius: 0.25rem; font-size: 1rem; font-weight: 500; cursor: pointer;"
+
>
+
Reset Password
+
</button>
+
</form>
+
+
<div style="text-align: center; margin-top: 1.5rem;">
+
<a href="/" style="color: var(--primary); text-decoration: none;">Back to home</a>
+
</div>
+
</div>
+
+
<div id="success-message" style="display: none; text-align: center;">
+
<div style="color: var(--primary); margin-bottom: 1rem;">
+
✓ Password reset successfully!
+
</div>
+
<a href="/" style="color: var(--accent); font-weight: 500; text-decoration: none;">Go to home</a>
+
</div>
+
</div>
+
</div>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module">
+
import { hashPasswordClient } from '../lib/client-auth.ts';
+
+
const form = document.getElementById('reset-form');
+
const passwordInput = document.getElementById('password');
+
const confirmPasswordInput = document.getElementById('confirm-password');
+
const submitBtn = document.getElementById('submit-btn');
+
const errorMessage = document.getElementById('error-message');
+
const formContainer = document.getElementById('form-container');
+
const successMessage = document.getElementById('success-message');
+
+
// Get token from URL
+
const urlParams = new URLSearchParams(window.location.search);
+
const token = urlParams.get('token');
+
+
if (!token) {
+
errorMessage.textContent = 'Invalid or missing reset token';
+
errorMessage.style.display = 'block';
+
form.style.display = 'none';
+
}
+
+
form?.addEventListener('submit', async (e) => {
+
e.preventDefault();
+
+
const password = passwordInput.value;
+
const confirmPassword = confirmPasswordInput.value;
+
+
// Validate passwords match
+
if (password !== confirmPassword) {
+
errorMessage.textContent = 'Passwords do not match';
+
errorMessage.style.display = 'block';
+
return;
+
}
+
+
// Validate password length
+
if (password.length < 8) {
+
errorMessage.textContent = 'Password must be at least 8 characters';
+
errorMessage.style.display = 'block';
+
return;
+
}
+
+
errorMessage.style.display = 'none';
+
submitBtn.disabled = true;
+
submitBtn.textContent = 'Resetting...';
+
+
try {
+
// Hash password client-side
+
const hashedPassword = await hashPasswordClient(password);
+
+
const response = await fetch('/api/auth/reset-password', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ token, password: hashedPassword }),
+
});
+
+
const data = await response.json();
+
+
if (!response.ok) {
+
throw new Error(data.error || 'Failed to reset password');
+
}
+
+
// Show success message
+
formContainer.style.display = 'none';
+
successMessage.style.display = 'block';
+
+
} catch (error) {
+
errorMessage.textContent = error.message || 'Failed to reset password';
+
errorMessage.style.display = 'block';
+
submitBtn.disabled = false;
+
submitBtn.textContent = 'Reset Password';
+
}
+
});
+
</script>
+
</body>
+
+
</html>