1import { Inject, Injectable } from "@nestjs/common";
2import { randomInt } from "node:crypto";
3import { sha256hex } from "../lib/crypto.js";
4import { loadEnv } from "../config/env.js";
5import { logger } from "./logger.js";
6import { PrismaService } from "./prisma.service.js";
7
8
15@Injectable()
16export class EmailService {
17 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
18
19
20 async sendPasswordResetCode(userId: string, email: string): Promise<string> {
21 const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
22 await this.prisma.passwordReset.create({
23 data: {
24 userId,
25 tokenHash: sha256hex(code),
26 status: "PENDING",
27 expiresAt: new Date(Date.now() + 60 * 60 * 1000)
28 }
29 });
30 this.transport(email, "Password reset", `Your reset code is ${code} (valid 60 minutes).`);
31 return code;
32 }
33
34
35 async consumePasswordResetCode(userId: string, code: string) {
36 const row = await this.prisma.passwordReset.findFirst({
37 where: { userId, tokenHash: sha256hex(code), status: "PENDING" },
38 orderBy: { createdAt: "desc" }
39 });
40 if (!row) return { status: "NOT_FOUND" as const };
41 if (row.expiresAt.getTime() < Date.now()) {
42 await this.prisma.passwordReset.update({ where: { id: row.id }, data: { status: "EXPIRED" } });
43 return { status: "EXPIRED" as const };
44 }
45 await this.prisma.passwordReset.update({ where: { id: row.id }, data: { status: "USED", usedAt: new Date() } });
46 return { status: "OK" as const, rowId: row.id };
47 }
48
49
50 async sendVerification(userId: string, email: string): Promise<void> {
51 const token = `evt_${sha256hex(`${userId}:${Date.now()}:${Math.random()}`).slice(0, 32)}`;
52 await this.prisma.emailVerification.create({
53 data: {
54 userId,
55 tokenHash: sha256hex(token),
56 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
57 }
58 });
59 this.transport(email, "Verify your email", `Open ${loadEnv().WEB_ORIGIN}/verify-email?token=${token}`);
60 }
61
62
63 sendInvitation(email: string, workspaceName: string, token: string): void {
64 this.transport(email, `Invitation to ${workspaceName}`, `${loadEnv().WEB_ORIGIN}/invite?token=${token}`);
65 }
66
67 private transport(to: string, subject: string, body: string): void {
68 const env = loadEnv();
69 if (!env.SMTP_HOST) {
70 logger.info(`email (stub — no SMTP configured) to=${to} subject="${subject}"`, { preview: body.slice(0, 120) });
71 return;
72 }
73
74 logger.info(`email queued (transport handled by worker) to=${to} subject="${subject}"`);
75 }
76}
77