| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import nodemailer from 'nodemailer'; |
|
|
| const FROM_EMAIL = process.env.FROM_EMAIL || 'MedOS <onboarding@resend.dev>'; |
| const APP_NAME = 'MedOS'; |
| const APP_URL = process.env.APP_URL || 'https://ruslanmv-medibot.hf.space'; |
|
|
| |
|
|
| const RESEND_API_KEY = process.env.RESEND_API_KEY; |
| const RESEND_URL = 'https://api.resend.com/emails'; |
|
|
| async function sendViaResend( |
| to: string, |
| subject: string, |
| html: string, |
| ): Promise<boolean> { |
| try { |
| const res = await fetch(RESEND_URL, { |
| method: 'POST', |
| headers: { |
| Authorization: `Bearer ${RESEND_API_KEY}`, |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify({ from: FROM_EMAIL, to, subject, html }), |
| }); |
| if (!res.ok) { |
| const errBody = await res.text().catch(() => ''); |
| console.error( |
| `[EMAIL Resend] ${res.status} ${res.statusText} → to=${to}: ${errBody.slice(0, 300)}`, |
| ); |
| return false; |
| } |
| const data = (await res.json().catch(() => ({}))) as { id?: string }; |
| console.log(`[EMAIL Resend] ok id=${data.id ?? '?'} to=${to} subject="${subject}"`); |
| return true; |
| } catch (err: any) { |
| console.error(`[EMAIL Resend] network error to=${to}: ${err?.message ?? err}`); |
| return false; |
| } |
| } |
|
|
| |
|
|
| const SMTP_HOST = process.env.SMTP_HOST; |
| const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10); |
| const SMTP_USER = process.env.SMTP_USER; |
| const SMTP_PASS = process.env.SMTP_PASS; |
|
|
| const smtpConfigured = !!(SMTP_HOST && SMTP_USER && SMTP_PASS); |
|
|
| const smtpTransporter = smtpConfigured |
| ? nodemailer.createTransport({ |
| host: SMTP_HOST, |
| port: SMTP_PORT, |
| secure: SMTP_PORT === 465, |
| auth: { user: SMTP_USER, pass: SMTP_PASS }, |
| }) |
| : null; |
|
|
| async function sendViaSmtp( |
| to: string, |
| subject: string, |
| html: string, |
| ): Promise<boolean> { |
| if (!smtpTransporter) return false; |
| try { |
| const info = await smtpTransporter.sendMail({ from: FROM_EMAIL, to, subject, html }); |
| console.log(`[EMAIL SMTP] ok messageId=${info.messageId} to=${to} subject="${subject}"`); |
| return true; |
| } catch (err: any) { |
| console.error(`[EMAIL SMTP] failed to=${to}: ${err?.message ?? err}`); |
| return false; |
| } |
| } |
|
|
| |
|
|
| function logToConsole(to: string, subject: string, html: string): boolean { |
| console.log( |
| `\n[EMAIL stdout-fallback] No RESEND_API_KEY or SMTP_* configured. Set one to actually send mail.`, |
| ); |
| console.log(`[EMAIL] To: ${to}`); |
| console.log(`[EMAIL] Subject: ${subject}`); |
| console.log(`[EMAIL] Body (text): ${html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()}\n`); |
| return true; |
| } |
|
|
| |
|
|
| async function sendEmail(to: string, subject: string, html: string): Promise<boolean> { |
| if (RESEND_API_KEY) return sendViaResend(to, subject, html); |
| if (smtpConfigured) return sendViaSmtp(to, subject, html); |
| return logToConsole(to, subject, html); |
| } |
|
|
| |
| |
| |
| |
| |
| export function emailTransportName(): 'resend' | 'smtp' | 'console' { |
| if (RESEND_API_KEY) return 'resend'; |
| if (smtpConfigured) return 'smtp'; |
| return 'console'; |
| } |
|
|
| |
| |
| |
|
|
| function wrap(content: string): string { |
| return ` |
| <!DOCTYPE html> |
| <html> |
| <head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head> |
| <body style="margin:0;padding:0;background:#f7f9fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> |
| <div style="max-width:480px;margin:40px auto;padding:32px;background:#ffffff;border-radius:16px;border:1px solid #e2e8f0;"> |
| <div style="text-align:center;margin-bottom:24px;"> |
| <div style="display:inline-block;width:48px;height:48px;border-radius:14px;background:linear-gradient(135deg,#3b82f6,#14b8a6);line-height:48px;font-size:24px;color:white;">♥</div> |
| <h2 style="margin:12px 0 0;color:#0f172a;font-size:20px;">${APP_NAME}</h2> |
| </div> |
| ${content} |
| <hr style="border:none;border-top:1px solid #e2e8f0;margin:24px 0;"> |
| <p style="color:#94a3b8;font-size:12px;text-align:center;margin:0;"> |
| This email was sent by ${APP_NAME}. If you didn't request this, you can safely ignore it. |
| </p> |
| </div> |
| </body> |
| </html>`; |
| } |
|
|
| |
| |
| |
|
|
| export async function sendVerificationEmail( |
| to: string, |
| code: string, |
| ): Promise<boolean> { |
| return sendEmail( |
| to, |
| `${APP_NAME} — verify your email`, |
| wrap(` |
| <h3 style="color:#0f172a;font-size:18px;margin:0 0 8px;">Verify your email</h3> |
| <p style="color:#475569;font-size:14px;line-height:1.6;margin:0 0 20px;"> |
| Enter this code in the app to verify your email address and secure your account. |
| </p> |
| <div style="text-align:center;margin:24px 0;"> |
| <div style="display:inline-block;padding:16px 32px;background:#f1f5f9;border-radius:12px;border:2px dashed #cbd5e1;"> |
| <span style="font-size:32px;font-weight:800;letter-spacing:8px;color:#0f172a;">${code}</span> |
| </div> |
| </div> |
| <p style="color:#94a3b8;font-size:13px;text-align:center;margin:0;"> |
| This code expires in 15 minutes. |
| </p> |
| `), |
| ); |
| } |
|
|
| export async function sendPasswordResetEmail( |
| to: string, |
| code: string, |
| ): Promise<boolean> { |
| |
| |
| |
| |
| |
| const linkUrl = |
| `${APP_URL}?action=reset` + |
| `&email=${encodeURIComponent(to)}` + |
| `&code=${encodeURIComponent(code)}`; |
|
|
| return sendEmail( |
| to, |
| `${APP_NAME} — reset your password`, |
| wrap(` |
| <h3 style="color:#0f172a;font-size:18px;margin:0 0 8px;">Reset your password</h3> |
| <p style="color:#475569;font-size:14px;line-height:1.6;margin:0 0 20px;"> |
| Someone requested a password reset for your ${APP_NAME} account. Click the button below to set a new password, or enter the 6-digit code in the app. |
| </p> |
| <div style="text-align:center;margin:20px 0;"> |
| <a href="${linkUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#3b82f6,#14b8a6);color:white;text-decoration:none;border-radius:10px;font-weight:700;font-size:14px;"> |
| Reset password |
| </a> |
| </div> |
| <p style="color:#94a3b8;font-size:12px;text-align:center;margin:0 0 16px;"> |
| Or enter this code manually: |
| </p> |
| <div style="text-align:center;margin:8px 0 24px;"> |
| <div style="display:inline-block;padding:16px 32px;background:#f1f5f9;border-radius:12px;border:2px dashed #cbd5e1;"> |
| <span style="font-size:32px;font-weight:800;letter-spacing:8px;color:#0f172a;">${code}</span> |
| </div> |
| </div> |
| <p style="color:#94a3b8;font-size:13px;text-align:center;margin:0;"> |
| This code expires in 1 hour. If you didn't request this, ignore this email — your password will stay the same. |
| </p> |
| `), |
| ); |
| } |
|
|
| export async function sendWelcomeEmail(to: string): Promise<boolean> { |
| return sendEmail( |
| to, |
| `Welcome to ${APP_NAME}`, |
| wrap(` |
| <h3 style="color:#0f172a;font-size:18px;margin:0 0 8px;">Welcome to ${APP_NAME}</h3> |
| <p style="color:#475569;font-size:14px;line-height:1.6;margin:0 0 16px;"> |
| Your account is ready. You can now track medications, appointments, vitals, |
| and access your health data from any device. |
| </p> |
| <div style="text-align:center;margin:20px 0;"> |
| <a href="${APP_URL}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#3b82f6,#14b8a6);color:white;text-decoration:none;border-radius:10px;font-weight:700;font-size:14px;"> |
| Open ${APP_NAME} |
| </a> |
| </div> |
| <p style="color:#94a3b8;font-size:13px;text-align:center;margin:0;"> |
| Free forever. Private. No ads. |
| </p> |
| `), |
| ); |
| } |
|
|