@donkeylabs/cli 1.1.15 → 1.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/sveltekit-app/.env.example +3 -1
- package/templates/sveltekit-app/src/server/index.ts +11 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/004_create_passkeys.ts +60 -0
- package/templates/sveltekit-app/src/server/plugins/email/index.ts +411 -0
- package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts +33 -0
package/package.json
CHANGED
|
@@ -33,8 +33,10 @@ NODE_ENV=development
|
|
|
33
33
|
# EXTERNAL SERVICES (examples)
|
|
34
34
|
# =============================================================================
|
|
35
35
|
|
|
36
|
-
# Email service (
|
|
36
|
+
# Email service (Resend)
|
|
37
37
|
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
|
38
|
+
# EMAIL_FROM=noreply@yourdomain.com
|
|
39
|
+
# PUBLIC_BASE_URL=https://yourdomain.com
|
|
38
40
|
|
|
39
41
|
# Payment processing (e.g., Stripe)
|
|
40
42
|
# STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
|
|
@@ -6,6 +6,7 @@ import { Database } from "bun:sqlite";
|
|
|
6
6
|
import { demoPlugin } from "./plugins/demo";
|
|
7
7
|
import { workflowDemoPlugin } from "./plugins/workflow-demo";
|
|
8
8
|
import { authPlugin } from "./plugins/auth";
|
|
9
|
+
import { emailPlugin } from "./plugins/email";
|
|
9
10
|
import demoRoutes from "./routes/demo";
|
|
10
11
|
import { exampleRouter } from "./routes/example";
|
|
11
12
|
import { authRouter } from "./routes/auth";
|
|
@@ -56,6 +57,16 @@ export const server = new AppServer({
|
|
|
56
57
|
|
|
57
58
|
// Using default session strategy for this template
|
|
58
59
|
server.registerPlugin(authPlugin());
|
|
60
|
+
|
|
61
|
+
// Email plugin - supports Resend or console (for development)
|
|
62
|
+
// Configure with process.env.RESEND_API_KEY for production
|
|
63
|
+
server.registerPlugin(emailPlugin({
|
|
64
|
+
provider: process.env.RESEND_API_KEY ? "resend" : "console",
|
|
65
|
+
resend: process.env.RESEND_API_KEY ? { apiKey: process.env.RESEND_API_KEY } : undefined,
|
|
66
|
+
from: process.env.EMAIL_FROM || "noreply@example.com",
|
|
67
|
+
baseUrl: process.env.PUBLIC_BASE_URL || "http://localhost:5173",
|
|
68
|
+
}));
|
|
69
|
+
|
|
59
70
|
server.registerPlugin(demoPlugin);
|
|
60
71
|
server.registerPlugin(workflowDemoPlugin);
|
|
61
72
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
// Passkey credentials (WebAuthn)
|
|
5
|
+
await db.schema
|
|
6
|
+
.createTable("passkeys")
|
|
7
|
+
.ifNotExists()
|
|
8
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
9
|
+
.addColumn("user_id", "text", (col) =>
|
|
10
|
+
col.notNull().references("users.id").onDelete("cascade")
|
|
11
|
+
)
|
|
12
|
+
.addColumn("credential_id", "text", (col) => col.notNull().unique())
|
|
13
|
+
.addColumn("public_key", "text", (col) => col.notNull()) // Base64 encoded
|
|
14
|
+
.addColumn("counter", "integer", (col) => col.notNull().defaultTo(0))
|
|
15
|
+
.addColumn("device_type", "text") // platform, cross-platform
|
|
16
|
+
.addColumn("backed_up", "integer", (col) => col.notNull().defaultTo(0))
|
|
17
|
+
.addColumn("transports", "text") // JSON array
|
|
18
|
+
.addColumn("name", "text") // User-friendly name
|
|
19
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
20
|
+
.addColumn("last_used_at", "text")
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
await db.schema
|
|
24
|
+
.createIndex("idx_passkeys_user_id")
|
|
25
|
+
.ifNotExists()
|
|
26
|
+
.on("passkeys")
|
|
27
|
+
.column("user_id")
|
|
28
|
+
.execute();
|
|
29
|
+
|
|
30
|
+
await db.schema
|
|
31
|
+
.createIndex("idx_passkeys_credential_id")
|
|
32
|
+
.ifNotExists()
|
|
33
|
+
.on("passkeys")
|
|
34
|
+
.column("credential_id")
|
|
35
|
+
.execute();
|
|
36
|
+
|
|
37
|
+
// Passkey challenges (temporary storage)
|
|
38
|
+
await db.schema
|
|
39
|
+
.createTable("passkey_challenges")
|
|
40
|
+
.ifNotExists()
|
|
41
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
42
|
+
.addColumn("challenge", "text", (col) => col.notNull())
|
|
43
|
+
.addColumn("user_id", "text") // Null for registration
|
|
44
|
+
.addColumn("type", "text", (col) => col.notNull()) // registration, authentication
|
|
45
|
+
.addColumn("expires_at", "text", (col) => col.notNull())
|
|
46
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
47
|
+
.execute();
|
|
48
|
+
|
|
49
|
+
await db.schema
|
|
50
|
+
.createIndex("idx_passkey_challenges_expires_at")
|
|
51
|
+
.ifNotExists()
|
|
52
|
+
.on("passkey_challenges")
|
|
53
|
+
.column("expires_at")
|
|
54
|
+
.execute();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
58
|
+
await db.schema.dropTable("passkey_challenges").ifExists().execute();
|
|
59
|
+
await db.schema.dropTable("passkeys").ifExists().execute();
|
|
60
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Plugin - Transactional email with multiple providers
|
|
3
|
+
*
|
|
4
|
+
* Providers:
|
|
5
|
+
* - resend: Resend.com API
|
|
6
|
+
* - console: Log emails to console (development)
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Magic link generation and validation
|
|
10
|
+
* - Password reset tokens
|
|
11
|
+
* - Email verification
|
|
12
|
+
* - Template support
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createPlugin } from "@donkeylabs/server";
|
|
16
|
+
import type { ColumnType } from "kysely";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// CONFIGURATION TYPES
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export type EmailProvider = "resend" | "console";
|
|
23
|
+
|
|
24
|
+
export interface EmailConfig {
|
|
25
|
+
/**
|
|
26
|
+
* Email provider
|
|
27
|
+
* @default "console"
|
|
28
|
+
*/
|
|
29
|
+
provider?: EmailProvider;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provider-specific configuration
|
|
33
|
+
*/
|
|
34
|
+
resend?: {
|
|
35
|
+
apiKey: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default from address
|
|
40
|
+
*/
|
|
41
|
+
from: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Base URL for links (e.g., "https://myapp.com")
|
|
45
|
+
*/
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Token expiry times
|
|
50
|
+
*/
|
|
51
|
+
expiry?: {
|
|
52
|
+
/** Magic link expiry @default "15m" */
|
|
53
|
+
magicLink?: string;
|
|
54
|
+
/** Password reset expiry @default "1h" */
|
|
55
|
+
passwordReset?: string;
|
|
56
|
+
/** Email verification expiry @default "24h" */
|
|
57
|
+
emailVerification?: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// DATABASE SCHEMA
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
interface EmailTokensTable {
|
|
66
|
+
id: string;
|
|
67
|
+
type: "magic_link" | "password_reset" | "email_verification";
|
|
68
|
+
email: string;
|
|
69
|
+
token_hash: string;
|
|
70
|
+
expires_at: string;
|
|
71
|
+
used_at: string | null;
|
|
72
|
+
created_at: ColumnType<string, string | undefined, never>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface EmailSchema {
|
|
76
|
+
email_tokens: EmailTokensTable;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// TYPES
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
export interface SendEmailOptions {
|
|
84
|
+
to: string | string[];
|
|
85
|
+
subject: string;
|
|
86
|
+
html?: string;
|
|
87
|
+
text?: string;
|
|
88
|
+
from?: string;
|
|
89
|
+
replyTo?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface EmailResult {
|
|
93
|
+
success: boolean;
|
|
94
|
+
messageId?: string;
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// HELPERS
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
function parseExpiry(expiry: string): number {
|
|
103
|
+
const match = expiry.match(/^(\d+)(s|m|h|d)$/);
|
|
104
|
+
if (!match) return 15 * 60 * 1000; // Default 15 minutes
|
|
105
|
+
|
|
106
|
+
const value = parseInt(match[1]);
|
|
107
|
+
const unit = match[2];
|
|
108
|
+
|
|
109
|
+
switch (unit) {
|
|
110
|
+
case "s": return value * 1000;
|
|
111
|
+
case "m": return value * 60 * 1000;
|
|
112
|
+
case "h": return value * 60 * 60 * 1000;
|
|
113
|
+
case "d": return value * 24 * 60 * 60 * 1000;
|
|
114
|
+
default: return 15 * 60 * 1000;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function generateToken(): string {
|
|
119
|
+
const bytes = new Uint8Array(32);
|
|
120
|
+
crypto.getRandomValues(bytes);
|
|
121
|
+
return Array.from(bytes)
|
|
122
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
123
|
+
.join("");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// =============================================================================
|
|
127
|
+
// PLUGIN DEFINITION
|
|
128
|
+
// =============================================================================
|
|
129
|
+
|
|
130
|
+
export const emailPlugin = createPlugin
|
|
131
|
+
.withSchema<EmailSchema>()
|
|
132
|
+
.withConfig<EmailConfig>()
|
|
133
|
+
.define({
|
|
134
|
+
name: "email",
|
|
135
|
+
|
|
136
|
+
customErrors: {
|
|
137
|
+
InvalidToken: {
|
|
138
|
+
status: 400,
|
|
139
|
+
code: "INVALID_TOKEN",
|
|
140
|
+
message: "Invalid or expired token",
|
|
141
|
+
},
|
|
142
|
+
TokenAlreadyUsed: {
|
|
143
|
+
status: 400,
|
|
144
|
+
code: "TOKEN_USED",
|
|
145
|
+
message: "This token has already been used",
|
|
146
|
+
},
|
|
147
|
+
SendFailed: {
|
|
148
|
+
status: 500,
|
|
149
|
+
code: "EMAIL_SEND_FAILED",
|
|
150
|
+
message: "Failed to send email",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
service: async (ctx) => {
|
|
155
|
+
const config = ctx.config!;
|
|
156
|
+
const provider = config.provider || "console";
|
|
157
|
+
const from = config.from;
|
|
158
|
+
const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash
|
|
159
|
+
|
|
160
|
+
const magicLinkExpiry = parseExpiry(config.expiry?.magicLink || "15m");
|
|
161
|
+
const passwordResetExpiry = parseExpiry(config.expiry?.passwordReset || "1h");
|
|
162
|
+
const emailVerificationExpiry = parseExpiry(config.expiry?.emailVerification || "24h");
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Send email using configured provider
|
|
166
|
+
*/
|
|
167
|
+
async function sendEmail(options: SendEmailOptions): Promise<EmailResult> {
|
|
168
|
+
const emailFrom = options.from || from;
|
|
169
|
+
const recipients = Array.isArray(options.to) ? options.to : [options.to];
|
|
170
|
+
|
|
171
|
+
if (provider === "console") {
|
|
172
|
+
ctx.core.logger.info("Email sent (console provider)", {
|
|
173
|
+
to: recipients,
|
|
174
|
+
subject: options.subject,
|
|
175
|
+
from: emailFrom,
|
|
176
|
+
});
|
|
177
|
+
console.log("\n" + "=".repeat(60));
|
|
178
|
+
console.log(`📧 EMAIL TO: ${recipients.join(", ")}`);
|
|
179
|
+
console.log(` FROM: ${emailFrom}`);
|
|
180
|
+
console.log(` SUBJECT: ${options.subject}`);
|
|
181
|
+
console.log("-".repeat(60));
|
|
182
|
+
console.log(options.text || options.html);
|
|
183
|
+
console.log("=".repeat(60) + "\n");
|
|
184
|
+
return { success: true, messageId: `console-${Date.now()}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (provider === "resend") {
|
|
188
|
+
if (!config.resend?.apiKey) {
|
|
189
|
+
throw new Error("Resend API key not configured");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: {
|
|
196
|
+
"Authorization": `Bearer ${config.resend.apiKey}`,
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
from: emailFrom,
|
|
201
|
+
to: recipients,
|
|
202
|
+
subject: options.subject,
|
|
203
|
+
html: options.html,
|
|
204
|
+
text: options.text,
|
|
205
|
+
reply_to: options.replyTo,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
const error = await response.text();
|
|
211
|
+
ctx.core.logger.error("Resend API error", { error, status: response.status });
|
|
212
|
+
return { success: false, error };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const data = await response.json() as { id: string };
|
|
216
|
+
return { success: true, messageId: data.id };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
ctx.core.logger.error("Failed to send email via Resend", { error });
|
|
219
|
+
return { success: false, error: String(error) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { success: false, error: `Unknown provider: ${provider}` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a token and store hash
|
|
228
|
+
*/
|
|
229
|
+
async function createToken(
|
|
230
|
+
type: "magic_link" | "password_reset" | "email_verification",
|
|
231
|
+
email: string,
|
|
232
|
+
expiryMs: number
|
|
233
|
+
): Promise<string> {
|
|
234
|
+
const token = generateToken();
|
|
235
|
+
const tokenHash = await Bun.password.hash(token, {
|
|
236
|
+
algorithm: "bcrypt",
|
|
237
|
+
cost: 4, // Low cost for tokens
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await ctx.db
|
|
241
|
+
.insertInto("email_tokens")
|
|
242
|
+
.values({
|
|
243
|
+
id: crypto.randomUUID(),
|
|
244
|
+
type,
|
|
245
|
+
email: email.toLowerCase(),
|
|
246
|
+
token_hash: tokenHash,
|
|
247
|
+
expires_at: new Date(Date.now() + expiryMs).toISOString(),
|
|
248
|
+
used_at: null,
|
|
249
|
+
created_at: new Date().toISOString(),
|
|
250
|
+
})
|
|
251
|
+
.execute();
|
|
252
|
+
|
|
253
|
+
return token;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Validate and consume a token
|
|
258
|
+
*/
|
|
259
|
+
async function validateToken(
|
|
260
|
+
type: "magic_link" | "password_reset" | "email_verification",
|
|
261
|
+
email: string,
|
|
262
|
+
token: string
|
|
263
|
+
): Promise<boolean> {
|
|
264
|
+
const tokens = await ctx.db
|
|
265
|
+
.selectFrom("email_tokens")
|
|
266
|
+
.where("type", "=", type)
|
|
267
|
+
.where("email", "=", email.toLowerCase())
|
|
268
|
+
.where("used_at", "is", null)
|
|
269
|
+
.where("expires_at", ">", new Date().toISOString())
|
|
270
|
+
.selectAll()
|
|
271
|
+
.execute();
|
|
272
|
+
|
|
273
|
+
for (const stored of tokens) {
|
|
274
|
+
const valid = await Bun.password.verify(token, stored.token_hash);
|
|
275
|
+
if (valid) {
|
|
276
|
+
// Mark as used
|
|
277
|
+
await ctx.db
|
|
278
|
+
.updateTable("email_tokens")
|
|
279
|
+
.set({ used_at: new Date().toISOString() })
|
|
280
|
+
.where("id", "=", stored.id)
|
|
281
|
+
.execute();
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
/**
|
|
291
|
+
* Send a raw email
|
|
292
|
+
*/
|
|
293
|
+
send: sendEmail,
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Send magic link for passwordless login
|
|
297
|
+
*/
|
|
298
|
+
sendMagicLink: async (email: string, redirectPath: string = "/"): Promise<EmailResult> => {
|
|
299
|
+
const token = await createToken("magic_link", email, magicLinkExpiry);
|
|
300
|
+
const magicLink = `${baseUrl}/auth/magic?token=${token}&email=${encodeURIComponent(email)}&redirect=${encodeURIComponent(redirectPath)}`;
|
|
301
|
+
|
|
302
|
+
return sendEmail({
|
|
303
|
+
to: email,
|
|
304
|
+
subject: "Sign in to your account",
|
|
305
|
+
html: `
|
|
306
|
+
<h2>Sign in to your account</h2>
|
|
307
|
+
<p>Click the link below to sign in. This link expires in 15 minutes.</p>
|
|
308
|
+
<p><a href="${magicLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Sign In</a></p>
|
|
309
|
+
<p style="color: #666; font-size: 14px;">Or copy this link: ${magicLink}</p>
|
|
310
|
+
<p style="color: #999; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
|
|
311
|
+
`,
|
|
312
|
+
text: `Sign in to your account\n\nClick here to sign in: ${magicLink}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`,
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Validate magic link token and return email if valid
|
|
318
|
+
*/
|
|
319
|
+
validateMagicLink: async (email: string, token: string): Promise<boolean> => {
|
|
320
|
+
return validateToken("magic_link", email, token);
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Send password reset email
|
|
325
|
+
*/
|
|
326
|
+
sendPasswordReset: async (email: string): Promise<EmailResult> => {
|
|
327
|
+
const token = await createToken("password_reset", email, passwordResetExpiry);
|
|
328
|
+
const resetLink = `${baseUrl}/auth/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
|
|
329
|
+
|
|
330
|
+
return sendEmail({
|
|
331
|
+
to: email,
|
|
332
|
+
subject: "Reset your password",
|
|
333
|
+
html: `
|
|
334
|
+
<h2>Reset your password</h2>
|
|
335
|
+
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
|
|
336
|
+
<p><a href="${resetLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Reset Password</a></p>
|
|
337
|
+
<p style="color: #666; font-size: 14px;">Or copy this link: ${resetLink}</p>
|
|
338
|
+
<p style="color: #999; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
|
|
339
|
+
`,
|
|
340
|
+
text: `Reset your password\n\nClick here to reset: ${resetLink}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, you can safely ignore this email.`,
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Validate password reset token
|
|
346
|
+
*/
|
|
347
|
+
validatePasswordReset: async (email: string, token: string): Promise<boolean> => {
|
|
348
|
+
return validateToken("password_reset", email, token);
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Send email verification
|
|
353
|
+
*/
|
|
354
|
+
sendVerification: async (email: string): Promise<EmailResult> => {
|
|
355
|
+
const token = await createToken("email_verification", email, emailVerificationExpiry);
|
|
356
|
+
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}&email=${encodeURIComponent(email)}`;
|
|
357
|
+
|
|
358
|
+
return sendEmail({
|
|
359
|
+
to: email,
|
|
360
|
+
subject: "Verify your email address",
|
|
361
|
+
html: `
|
|
362
|
+
<h2>Verify your email address</h2>
|
|
363
|
+
<p>Click the link below to verify your email. This link expires in 24 hours.</p>
|
|
364
|
+
<p><a href="${verifyLink}" style="display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 4px;">Verify Email</a></p>
|
|
365
|
+
<p style="color: #666; font-size: 14px;">Or copy this link: ${verifyLink}</p>
|
|
366
|
+
<p style="color: #999; font-size: 12px;">If you didn't create an account, you can safely ignore this email.</p>
|
|
367
|
+
`,
|
|
368
|
+
text: `Verify your email address\n\nClick here to verify: ${verifyLink}\n\nThis link expires in 24 hours.\n\nIf you didn't create an account, you can safely ignore this email.`,
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Validate email verification token
|
|
374
|
+
*/
|
|
375
|
+
validateVerification: async (email: string, token: string): Promise<boolean> => {
|
|
376
|
+
return validateToken("email_verification", email, token);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Cleanup expired tokens
|
|
381
|
+
*/
|
|
382
|
+
cleanup: async (): Promise<number> => {
|
|
383
|
+
const result = await ctx.db
|
|
384
|
+
.deleteFrom("email_tokens")
|
|
385
|
+
.where("expires_at", "<", new Date().toISOString())
|
|
386
|
+
.executeTakeFirst();
|
|
387
|
+
|
|
388
|
+
const count = Number(result.numDeletedRows);
|
|
389
|
+
if (count > 0) {
|
|
390
|
+
ctx.core.logger.info("Cleaned up expired email tokens", { count });
|
|
391
|
+
}
|
|
392
|
+
return count;
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
init: async (ctx, service) => {
|
|
398
|
+
// Cleanup expired tokens daily
|
|
399
|
+
ctx.core.cron.schedule(
|
|
400
|
+
"0 4 * * *", // Daily at 4am
|
|
401
|
+
async () => {
|
|
402
|
+
await service.cleanup();
|
|
403
|
+
},
|
|
404
|
+
{ name: "email-token-cleanup" }
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
ctx.core.logger.info("Email plugin initialized", {
|
|
408
|
+
provider: ctx.config?.provider || "console",
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
});
|
package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
await db.schema
|
|
5
|
+
.createTable("email_tokens")
|
|
6
|
+
.ifNotExists()
|
|
7
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
8
|
+
.addColumn("type", "text", (col) => col.notNull()) // magic_link, password_reset, email_verification
|
|
9
|
+
.addColumn("email", "text", (col) => col.notNull())
|
|
10
|
+
.addColumn("token_hash", "text", (col) => col.notNull())
|
|
11
|
+
.addColumn("expires_at", "text", (col) => col.notNull())
|
|
12
|
+
.addColumn("used_at", "text")
|
|
13
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
14
|
+
.execute();
|
|
15
|
+
|
|
16
|
+
await db.schema
|
|
17
|
+
.createIndex("idx_email_tokens_email_type")
|
|
18
|
+
.ifNotExists()
|
|
19
|
+
.on("email_tokens")
|
|
20
|
+
.columns(["email", "type"])
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
await db.schema
|
|
24
|
+
.createIndex("idx_email_tokens_expires_at")
|
|
25
|
+
.ifNotExists()
|
|
26
|
+
.on("email_tokens")
|
|
27
|
+
.column("expires_at")
|
|
28
|
+
.execute();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
32
|
+
await db.schema.dropTable("email_tokens").ifExists().execute();
|
|
33
|
+
}
|