import { Inject, Injectable } from "@nestjs/common"; import { randomInt } from "node:crypto"; import { sha256hex } from "../lib/crypto.js"; import { loadEnv } from "../config/env.js"; import { logger } from "./logger.js"; import { PrismaService } from "./prisma.service.js"; /** * Outbound email — stub transport. * * The real SMTP transport lives in the worker (BullMQ mail jobs). Here we * generate one-time codes/tokens, persist them HASHED, and "send" by * logging (dev) so the whole flow works end-to-end without an SMTP server. */ @Injectable() export class EmailService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} /** Generate + store a 6-digit password-reset code; "send" it. */ async sendPasswordResetCode(userId: string, email: string): Promise { const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); await this.prisma.passwordReset.create({ data: { userId, tokenHash: sha256hex(code), status: "PENDING", expiresAt: new Date(Date.now() + 60 * 60 * 1000) // 1h } }); this.transport(email, "Password reset", `Your reset code is ${code} (valid 60 minutes).`); return code; } /** Verify a reset code; marks the row used. Returns the reset row. */ async consumePasswordResetCode(userId: string, code: string) { const row = await this.prisma.passwordReset.findFirst({ where: { userId, tokenHash: sha256hex(code), status: "PENDING" }, orderBy: { createdAt: "desc" } }); if (!row) return { status: "NOT_FOUND" as const }; if (row.expiresAt.getTime() < Date.now()) { await this.prisma.passwordReset.update({ where: { id: row.id }, data: { status: "EXPIRED" } }); return { status: "EXPIRED" as const }; } await this.prisma.passwordReset.update({ where: { id: row.id }, data: { status: "USED", usedAt: new Date() } }); return { status: "OK" as const, rowId: row.id }; } /** Generate + store an email-verification token (24h). */ async sendVerification(userId: string, email: string): Promise { const token = `evt_${sha256hex(`${userId}:${Date.now()}:${Math.random()}`).slice(0, 32)}`; await this.prisma.emailVerification.create({ data: { userId, tokenHash: sha256hex(token), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) } }); this.transport(email, "Verify your email", `Open ${loadEnv().WEB_ORIGIN}/verify-email?token=${token}`); } /** Workspace invitation mail (stub — stored invitation already carries the token). */ sendInvitation(email: string, workspaceName: string, token: string): void { this.transport(email, `Invitation to ${workspaceName}`, `${loadEnv().WEB_ORIGIN}/invite?token=${token}`); } private transport(to: string, subject: string, body: string): void { const env = loadEnv(); if (!env.SMTP_HOST) { logger.info(`email (stub — no SMTP configured) to=${to} subject="${subject}"`, { preview: body.slice(0, 120) }); return; } // Real SMTP delivery is a worker job; log here so the API stays fast. logger.info(`email queued (transport handled by worker) to=${to} subject="${subject}"`); } }