Emails transacionais com Resend - Magic Links e Templates profissionais
O Restaurantix usa uma arquitetura de adapters para emails, permitindo trocar provedores facilmente.
// src/application/infra/messaging/protocols/emailAdapter.ts export interface EmailAdapter { sendEmail(to: string, subject: string, html: string): Promise<any>; } // Interface simples e flexível // Permite implementar qualquer provedor de email // Retorna Promise para operações assíncronas
// src/application/infra/messaging/adapters/resendAdapter.ts import { Resend } from "resend"; import type { EmailAdapter } from "../protocols/emailAdapter"; import { env } from "@/env"; const resend = new Resend(env.RESEND_API_KEY); export class ResendAdapter implements EmailAdapter { async sendEmail(to: string, subject: string, html: string): Promise<any> { const { data, error } = await resend.emails.send({ from: "Restaurantix <noreply@restaurantix.com>", to: [to], subject, html, }); if (error) { console.log({ error }); return error; } console.log(data); return data; } } export const makeResendAdapter = () => { return new ResendAdapter(); };
// src/application/infra/messaging/factories/emailFactory.ts import { makeResendAdapter } from "../adapters/resendAdapter"; import type { EmailAdapter } from "../protocols/emailAdapter"; export class EmailService { private adapter: EmailAdapter; constructor(adapter: EmailAdapter) { this.adapter = adapter; } async sendEmail(to: string, subject: string, html: string): Promise<any> { await this.adapter.sendEmail(to, subject, html); } } type EmailProviderKeys = "resend" | "sendgrid" | "mailgun"; const emailProviders: Record<EmailProviderKeys, () => EmailAdapter> = { resend: makeResendAdapter, // sendgrid: makeSendGridAdapter, // Futuro // mailgun: makeMailgunAdapter, // Futuro }; export const makeEmailService = () => { const provider = (process.env.EMAIL_PROVIDER || "resend") as EmailProviderKeys; return new EmailService(emailProviders[provider]()); };
// 1. FLEXIBILIDADE - Trocar provedor via env EMAIL_PROVIDER=resend # Usar Resend EMAIL_PROVIDER=sendgrid # Usar SendGrid EMAIL_PROVIDER=mailgun # Usar Mailgun // 2. TESTABILIDADE - Mock fácil const mockAdapter = { sendEmail: jest.fn().mockResolvedValue({ success: true }) }; const emailService = new EmailService(mockAdapter); // 3. EXTENSIBILIDADE - Adicionar novos provedores const emailProviders = { resend: makeResendAdapter, sendgrid: makeSendGridAdapter, // ✅ Novo mailgun: makeMailgunAdapter, // ✅ Novo postmark: makePostmarkAdapter, // ✅ Novo }; // 4. SINGLE RESPONSIBILITY - Cada adapter faz uma coisa // ResendAdapter → Só integra com Resend // SendGridAdapter → Só integra com SendGrid // EmailService → Só orquestra o envio
// Template usado na rota send-auth-link.ts const magicLinkTemplate = ` <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;"> <h1 style="color: #4a5568;">Welcome to Restaurantix</h1> <p>Click the button below to sign in to your account:</p> <a href="${authLink.toString()}" style="display: inline-block; background-color: #3182ce; color: white; font-weight: bold; padding: 12px 24px; border-radius: 4px; text-decoration: none;" > Sign In </a> <p style="margin-top: 24px; color: #718096; font-size: 14px;"> If you didn't request this link, you can safely ignore this email. </p> </div> `; // Uso na rota await emailSender.sendEmail( email, "Authentication Link", magicLinkTemplate );
// Trecho da rota send-auth-link.ts export const sendAuthLink = csrf.post( "/auth-links/authenticate", async ({ body }) => { const { email } = body; // 1. Verificar se usuário existe const [userFound] = await db .select() .from(users) .where(eq(users.email, email)); if (!userFound) { return { message: "Authentication link sent" }; } // 2. Gerar código único const code = createId(); // 3. Salvar no banco com expiração await db.insert(authLinks).values({ userId: userFound.id, code, expiresAt: dayjs().add(1, "days").toDate(), }); // 4. Construir URL do magic link const authLink = new URL(`auth-links/authenticate`, env.API_BASE_URL); authLink.searchParams.set("code", code); authLink.searchParams.set("redirect", env.AUTH_REDIRECT_URL); // 5. ENVIAR EMAIL (se configurado) if (env.RESEND_API_KEY) { try { const emailSender = makeEmailService(); await emailSender.sendEmail( email, "Authentication Link", magicLinkTemplate ); } catch (error) { console.error("Failed to send authentication email:", error); // NÃO falha a requisição se email falhar } } return { message: "Authentication link sent" }; } );
// src/application/infra/messaging/templates/emailTemplates.ts export class EmailTemplates { static magicLink(authLink: string, userName?: string) { return ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Restaurantix - Magic Link</title> </head> <body style="margin: 0; padding: 0; background-color: #f7fafc;"> <div style="max-width: 600px; margin: 0 auto; background-color: white;"> <!-- Header --> <div style="background-color: #2d3748; padding: 20px; text-align: center;"> <h1 style="color: #68d391; margin: 0; font-size: 24px;">🍽️ Restaurantix</h1> </div> <!-- Content --> <div style="padding: 40px 20px;"> <h2 style="color: #2d3748; margin-bottom: 20px;"> ${userName ? `Olá, ${userName}!` : 'Olá!'} </h2> <p style="color: #4a5568; line-height: 1.6; margin-bottom: 30px;"> Clique no botão abaixo para acessar sua conta no Restaurantix. Este link é válido por 24 horas. </p> <!-- CTA Button --> <div style="text-align: center; margin: 40px 0;"> <a href="${authLink}" style="display: inline-block; background-color: #68d391; color: white; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: bold; font-size: 16px;"> Acessar Conta </a> </div> <p style="color: #718096; font-size: 14px; line-height: 1.6;"> Se você não solicitou este acesso, pode ignorar este email com segurança. <br><br> <strong>Link direto:</strong><br> <a href="${authLink}" style="color: #3182ce; word-break: break-all;"> ${authLink} </a> </p> </div> <!-- Footer --> <div style="background-color: #edf2f7; padding: 20px; text-align: center;"> <p style="color: #718096; font-size: 12px; margin: 0;"> © {new Date().getFullYear()} Restaurantix. Todos os direitos reservados. </p> </div> </div> </body> </html> `; } static orderConfirmation(orderData: any) { return ` <!DOCTYPE html> <html> <body style="font-family: Arial, sans-serif;"> <h2>Pedido Confirmado! 🎉</h2> <p>Seu pedido #${orderData.id} foi confirmado.</p> <p><strong>Total:</strong> R$ ${(orderData.total / 100).toFixed(2)}</p> <p><strong>Status:</strong> ${orderData.status}</p> </body> </html> `; } static passwordReset(resetLink: string) { return ` <!DOCTYPE html> <html> <body style="font-family: Arial, sans-serif;"> <h2>Redefinir Senha</h2> <p>Clique no link abaixo para redefinir sua senha:</p> <a href="${resetLink}">Redefinir Senha</a> </body> </html> `; } }
// Na rota send-auth-link.ts import { EmailTemplates } from "@/application/infra/messaging/templates/emailTemplates"; // Usar template profissional const htmlContent = EmailTemplates.magicLink( authLink.toString(), userFound.name ); await emailSender.sendEmail( email, "Acesse sua conta - Restaurantix", htmlContent ); // Para outros tipos de email const orderHtml = EmailTemplates.orderConfirmation({ id: "order-123", total: 2590, status: "confirmed" }); await emailSender.sendEmail( customerEmail, "Pedido Confirmado - Restaurantix", orderHtml );
# .env RESEND_API_KEY="re_123456789" EMAIL_PROVIDER="resend" API_BASE_URL="https://api.restaurantix.com" AUTH_REDIRECT_URL="https://app.restaurantix.com" # Configuração do domínio no Resend # 1. Adicionar domínio: restaurantix.com # 2. Configurar DNS records # 3. Verificar domínio # 4. Usar: "Restaurantix <noreply@restaurantix.com>"
// Implementação robusta com fallbacks export class EmailService { async sendEmail(to: string, subject: string, html: string) { try { const result = await this.adapter.sendEmail(to, subject, html); // Log de sucesso console.log(`✅ Email sent to ${to}: ${subject}`); return result; } catch (error) { // Log de erro detalhado console.error(`❌ Failed to send email to ${to}:`, { subject, error: error.message, timestamp: new Date().toISOString() }); // Não quebrar a aplicação // Pode implementar retry logic aqui throw error; } } } // Na rota, sempre usar try/catch if (env.RESEND_API_KEY) { try { const emailSender = makeEmailService(); await emailSender.sendEmail(email, subject, html); } catch (error) { console.error("Email failed, but continuing:", error); // Não falha a requisição principal } }
Implemente o sistema completo de emails do Restaurantix:
# Solicitar magic link
curl -X POST http://localhost:3000/auth-links/authenticate \
-H "Content-Type: application/json" \
-d '{"email": "seu@email.com"}'
# Verificar logs do servidor
tail -f logs/email.log