@nebutra/email 0.1.0 → 0.1.2
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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-test.log +16 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -676
- package/README.md +21 -4
- package/dist/index.d.ts +415 -0
- package/dist/index.js +740 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -4
- package/src/__tests__/provider.test.ts +5 -5
- package/src/index.ts +85 -30
- package/src/templates/_layout.ts +15 -6
- package/src/templates/invitation.tsx +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/providers/console.ts","../src/providers/nodemailer.ts","../src/providers/resend.ts","../src/provider.ts","../src/templates/invitation.tsx","../src/templates/_layout.ts","../src/templates/password-reset.tsx","../src/templates/receipt.tsx","../src/templates/welcome.tsx","../src/templates/index.ts"],"sourcesContent":["/**\n * @nebutra/email — Multi-provider transactional email\n *\n * Provider-agnostic email system with auto-detection:\n * 1. EMAIL_PROVIDER env var (explicit: \"resend\" | \"nodemailer\" | \"console\")\n * 2. RESEND_API_KEY present → Resend\n * 3. SMTP_HOST present → Nodemailer (SMTP)\n * 4. Fallback → Console (dev/test, no API key needed)\n *\n * Usage:\n * import { sendWelcomeEmail, sendApiKeyCreatedEmail } from \"@nebutra/email\";\n * await sendWelcomeEmail({ to: \"user@example.com\", orgName: \"Acme Corp\" });\n *\n * Environment variables:\n * EMAIL_PROVIDER — explicit provider override (optional)\n * EMAIL_FROM — verified sender (e.g. getBrandMailFrom() example)\n * RESEND_API_KEY — for Resend provider\n * SMTP_HOST — for Nodemailer provider (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE)\n */\n\nimport { brand, colors } from \"@nebutra/brand/metadata\";\nimport { getBrandMailFrom, getBrandOrigin } from \"@nebutra/brand/metadata-helpers\";\nimport { getEmailProvider, type SendResult } from \"./provider\";\n\nexport type { EmailProvider, EmailProviderType, SendOptions, SendResult } from \"./provider\";\nexport { getEmailProvider, resetEmailProvider } from \"./provider\";\nexport { ConsoleEmailProvider } from \"./providers/console\";\nexport { NodemailerEmailProvider } from \"./providers/nodemailer\";\nexport { ResendEmailProvider } from \"./providers/resend\";\n\nconst FROM = process.env.EMAIL_FROM ?? getBrandMailFrom();\n\n// ── Types ──────────────────────────────────────────────────────────────────\n\nexport interface EmailTemplateCatalogEntry {\n id: string;\n label: string;\n description: string;\n sendHelper: string;\n fileName: string;\n}\n\nexport const EMAIL_TEMPLATE_CATALOG = [\n {\n id: \"welcome\",\n label: \"Welcome\",\n description: \"Workspace provisioning welcome email\",\n sendHelper: \"sendWelcomeEmail\",\n fileName: \"welcome-email.html\",\n },\n {\n id: \"order-confirmation\",\n label: \"Order Confirmation\",\n description: \"Commerce order receipt with line items\",\n sendHelper: \"sendOrderConfirmationEmail\",\n fileName: \"order-confirmation-email.html\",\n },\n {\n id: \"magic-link\",\n label: \"Magic Link\",\n description: \"Passwordless sign-in link\",\n sendHelper: \"sendMagicLinkEmail\",\n fileName: \"magic-link-email.html\",\n },\n {\n id: \"email-change\",\n label: \"Email Change\",\n description: \"Confirm a new account email address\",\n sendHelper: \"sendEmailChangeEmail\",\n fileName: \"email-change-email.html\",\n },\n {\n id: \"contact-form-received\",\n label: \"Contact Form Received\",\n description: \"Contact form acknowledgement\",\n sendHelper: \"sendContactFormReceivedEmail\",\n fileName: \"contact-form-received-email.html\",\n },\n {\n id: \"license-created\",\n label: \"License Created\",\n description: \"License key delivery email\",\n sendHelper: \"sendLicenseCreatedEmail\",\n fileName: \"license-created-email.html\",\n },\n {\n id: \"welcome-react\",\n label: \"Welcome (React Email)\",\n description: \"Account onboarding welcome message\",\n sendHelper: \"sendWelcomeReactEmail\",\n fileName: \"welcome-react-email.html\",\n },\n {\n id: \"password-reset\",\n label: \"Password Reset\",\n description: \"Secure password reset link with expiry\",\n sendHelper: \"sendPasswordResetEmail\",\n fileName: \"password-reset-email.html\",\n },\n {\n id: \"invitation\",\n label: \"Invitation\",\n description: \"Workspace invitation with role and expiry\",\n sendHelper: \"sendInvitationEmail\",\n fileName: \"invitation-email.html\",\n },\n {\n id: \"receipt\",\n label: \"Receipt\",\n description: \"Payment receipt with billing period and PDF link\",\n sendHelper: \"sendReceiptEmail\",\n fileName: \"receipt-email.html\",\n },\n] as const satisfies readonly EmailTemplateCatalogEntry[];\n\n// ── Core send helper ───────────────────────────────────────────────────────\n\nasync function send(opts: {\n to: string | string[];\n subject: string;\n html: string;\n replyTo?: string;\n tags?: { name: string; value: string }[];\n}): Promise<SendResult> {\n const provider = getEmailProvider();\n return provider.send({\n ...opts,\n from: FROM,\n });\n}\n\n// ── Templates ──────────────────────────────────────────────────────────────\n\nfunction baseLayout(content: string, previewText = \"\"): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"x-apple-disable-message-reformatting\" />\n <title>${brand.name}</title>\n <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->\n</head>\n<body style=\"margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;\">\n ${previewText ? `<div style=\"display:none;max-height:0;overflow:hidden;\">${previewText} ‌ </div>` : \"\"}\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"background:#f8fafc;\">\n <tr><td align=\"center\" style=\"padding:40px 16px;\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;overflow:hidden;max-width:600px;width:100%;\">\n <!-- Header -->\n <tr><td style=\"background:${colors.primary[\"500\"]};padding:32px 40px;\">\n <h1 style=\"margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;\">${brand.name}</h1>\n </td></tr>\n <!-- Body -->\n <tr><td style=\"padding:40px;\">\n ${content}\n </td></tr>\n <!-- Footer -->\n <tr><td style=\"padding:24px 40px;border-top:1px solid #e2e8f0;background:#f8fafc;\">\n <p style=\"margin:0;font-size:12px;color:#94a3b8;text-align:center;\">\n © ${new Date().getFullYear()} ${brand.nameFullEn} ·\n <a href=\"${getBrandOrigin(\"landing\")}/privacy\" style=\"color:#94a3b8;\">Privacy</a> ·\n <a href=\"${getBrandOrigin(\"landing\")}/unsubscribe\" style=\"color:#94a3b8;\">Unsubscribe</a>\n </p>\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>`;\n}\n\n// ── Email senders ──────────────────────────────────────────────────────────\n\n/**\n * Welcome email sent when a new organization is provisioned.\n */\nexport async function sendWelcomeEmail(opts: {\n to: string;\n firstName: string;\n orgName: string;\n dashboardUrl?: string;\n}): Promise<SendResult> {\n const dashboardUrl = opts.dashboardUrl ?? getBrandOrigin(\"app\");\n\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Welcome to ${brand.name}, ${opts.firstName}!</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Your workspace <strong>${opts.orgName}</strong> is ready. You can now invite team members,\n create API keys, and start building with the ${brand.name} platform.\n </p>\n <a href=\"${dashboardUrl}\" style=\"display:inline-block;background:${colors.primary[\"500\"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n Open Dashboard →\n </a>\n <p style=\"margin:0;font-size:13px;color:#94a3b8;\">\n If you have questions, reply to this email or visit our <a href=\"${getBrandOrigin(\"docs\")}\" style=\"color:${colors.primary[\"500\"]};\">documentation</a>.\n </p>\n `,\n `Welcome to ${brand.name} — ${opts.orgName} is ready`,\n );\n\n return send({\n to: opts.to,\n subject: `Welcome to ${brand.name} — ${opts.orgName} is ready`,\n html,\n tags: [{ name: \"type\", value: \"welcome\" }],\n });\n}\n\n/**\n * Order confirmation email sent when an order successfully completes.\n */\nexport async function sendOrderConfirmationEmail(opts: {\n to: string;\n orderId: string;\n /** Order total in minor units (integer cents). */\n totalAmount: number;\n /** ISO 4217 currency code (default \"USD\"). */\n currency?: string;\n /** BCP 47 locale tag (default \"en-US\"). */\n locale?: string;\n items: Array<{ productId: string; quantity: number | string }>;\n}): Promise<SendResult> {\n const formattedTotal = new Intl.NumberFormat(opts.locale ?? \"en-US\", {\n style: \"currency\",\n currency: opts.currency ?? \"USD\",\n }).format(opts.totalAmount / 100);\n\n const itemsHtml = opts.items\n .map(\n (item) =>\n `<tr>\n <td style=\"padding:12px 0;border-bottom:1px solid #e2e8f0;color:#0f172a;font-size:15px;\">${item.productId}</td>\n <td style=\"padding:12px 0;border-bottom:1px solid #e2e8f0;color:#475569;font-size:15px;text-align:right;\">x${item.quantity}</td>\n </tr>`,\n )\n .join(\"\");\n\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Order Confirmed!</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Thank you for your order <strong>#${opts.orderId}</strong>. We've received your payment of\n <strong>${formattedTotal}</strong> and your order is now being processed.\n </p>\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:0 0 24px;border-collapse:collapse;\">\n ${itemsHtml}\n </table>\n <a href=\"${getBrandOrigin(\"app\")}/orders/${opts.orderId}\" style=\"display:inline-block;background:${colors.primary[\"500\"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n View Order Details →\n </a>\n `,\n `Order #${opts.orderId} Confirmed`,\n );\n\n return send({\n to: opts.to,\n subject: `Order #${opts.orderId} Confirmed`,\n html,\n tags: [{ name: \"type\", value: \"order_confirmation\" }],\n });\n}\n\n/**\n * Shipfast-style Magic Link Email for 1-click Authentication.\n */\nexport async function sendMagicLinkEmail(opts: {\n to: string;\n magicLinkUrl: string;\n}): Promise<SendResult> {\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Sign in to ${brand.name}</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Click the button below to sign in securely. No password required.\n </p>\n <a href=\"${opts.magicLinkUrl}\" style=\"display:inline-block;background:${colors.primary[\"500\"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;\">\n ✨ Sign In Automatically\n </a>\n <p style=\"margin:0;font-size:12px;color:#94a3b8;line-height:1.4;\">\n If you did not request this email, you can safely ignore it.\n This link expires in 15 minutes.\n </p>\n `,\n `Click here to sign in to your ${brand.name} account`,\n );\n\n return send({\n to: opts.to,\n subject: `Sign in to ${brand.name} ✨`,\n html,\n tags: [{ name: \"type\", value: \"magic_link\" }],\n });\n}\n\n/**\n * Email-change verification sent to the *new* address (TODO #126).\n * Confirming the link flips the account email.\n */\nexport async function sendEmailChangeEmail(opts: {\n to: string;\n confirmUrl: string;\n /** Optional display name for the new address (defaults to `to`). */\n newEmail?: string;\n}): Promise<SendResult> {\n const recipientEmail = opts.newEmail ?? opts.to;\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Confirm your new email address</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n We received a request to change the email on your ${brand.name} account to\n <strong>${recipientEmail}</strong>. Click the button below to confirm. This link expires in 1 hour.\n </p>\n <a href=\"${opts.confirmUrl}\" style=\"display:inline-block;background:${colors.primary[\"500\"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;\">\n Confirm email change →\n </a>\n <p style=\"margin:0;font-size:12px;color:#94a3b8;line-height:1.4;\">\n If you did not request this change you can safely ignore this email — your address will not be updated.\n </p>\n `,\n \"Confirm your new Nebutra email address\",\n );\n\n return send({\n to: opts.to,\n subject: \"Confirm your new Nebutra email address\",\n html,\n tags: [{ name: \"type\", value: \"email_change_verification\" }],\n });\n}\n\n/**\n * Contact Form Receipt (Sent to the User)\n */\nexport async function sendContactFormReceivedEmail(opts: {\n to: string;\n name: string;\n subject: string;\n}): Promise<SendResult> {\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Message Received</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Hi ${opts.name},\n </p>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Thank you for contacting us regarding \"<strong>${opts.subject}</strong>\". \n We've received your request and our team will get back to you within 1-2 business days.\n </p>\n <p style=\"margin:0;font-size:15px;color:#475569;line-height:1.6;\">\n Best regards,<br/>The ${brand.name} Team\n </p>\n `,\n \"We have received your message\",\n );\n\n return send({\n to: opts.to,\n subject: `We've received your message - ${brand.name}`,\n html,\n tags: [{ name: \"type\", value: \"contact_receipt\" }],\n });\n}\n\n// ── Billing Lifecycle Emails ───────────────────────────────────────────────\n\n/**\n * License created email sent when a user claims their OPC/STARTUP license.\n */\nexport async function sendLicenseCreatedEmail(opts: {\n to: string;\n firstName: string;\n licenseKey: string;\n tier: string;\n}): Promise<SendResult> {\n const html = baseLayout(\n `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:#0f172a;\">Welcome to ${brand.name}-Sailor! 🚀</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Hi ${opts.firstName}, your <strong>${opts.tier}</strong> license has been successfully generated.\n </p>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n Your official License Key is:\n </p>\n <div style=\"background:#0f172a;border-radius:8px;padding:16px 20px;margin:0 0 24px;overflow-x:auto;\">\n <code style=\"color:${colors.accent[\"500\"]};font-family:'Courier New',monospace;font-size:13px;word-break:break-all;\">${opts.licenseKey}</code>\n </div>\n <p style=\"margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;\">\n To unlock the premium capabilities of the ${brand.name} CLI locally, run the following command in your terminal:\n </p>\n <div style=\"background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:12px;margin:0 0 24px;\">\n <code style=\"color:#334155;font-family:'Courier New',monospace;font-size:13px;\">nebutra license activate ${opts.licenseKey}</code>\n </div>\n <p style=\"margin:0;font-size:13px;color:#94a3b8;\">\n If you have questions, reply to this email or visit our <a href=\"${getBrandOrigin(\"docs\")}\" style=\"color:${colors.primary[\"500\"]};\">documentation</a>.\n </p>\n `,\n `Your ${brand.name} License Key is Ready`,\n );\n\n return send({\n to: opts.to,\n subject: `Your ${brand.name} License Key is Ready (${opts.tier})`,\n html,\n tags: [{ name: \"type\", value: \"license_created\" }],\n });\n}\n\n// ── React Email-style templates (welcome, password-reset, invitation, receipt) ─\n\nimport {\n type InvitationEmailProps,\n invitationEmail,\n type PasswordResetEmailProps,\n passwordResetEmail,\n type ReceiptEmailProps,\n receiptEmail,\n type WelcomeEmailProps,\n welcomeEmail,\n} from \"./templates\";\n\nexport {\n type InvitationEmailProps,\n invitationEmail,\n type PasswordResetEmailProps,\n passwordResetEmail,\n REACT_EMAIL_TEMPLATES,\n type ReactEmailTemplate,\n type ReceiptEmailProps,\n type RenderedEmail,\n receiptEmail,\n renderInvitationEmail,\n renderPasswordResetEmail,\n renderReceiptEmail,\n renderWelcomeEmail,\n type WelcomeEmailProps,\n welcomeEmail,\n} from \"./templates\";\n\n/**\n * Send the React Email-style welcome message. Mirrors `WelcomeEmailProps`\n * exactly so callers can compose messages without depending on the legacy\n * `sendWelcomeEmail` signature.\n */\nexport async function sendWelcomeReactEmail(\n opts: { to: string } & WelcomeEmailProps,\n): Promise<SendResult> {\n return send({\n to: opts.to,\n subject: welcomeEmail.subject(opts),\n html: welcomeEmail.render(opts),\n tags: [{ name: \"type\", value: \"welcome_react\" }],\n });\n}\n\n/**\n * Send a password reset email with a tokenized link and expiry note.\n */\nexport async function sendPasswordResetEmail(\n opts: { to: string } & PasswordResetEmailProps,\n): Promise<SendResult> {\n return send({\n to: opts.to,\n subject: passwordResetEmail.subject(opts),\n html: passwordResetEmail.render(opts),\n tags: [{ name: \"type\", value: \"password_reset\" }],\n });\n}\n\n/**\n * Send a workspace invitation with role and accept link.\n */\nexport async function sendInvitationEmail(\n opts: { to: string } & InvitationEmailProps,\n): Promise<SendResult> {\n return send({\n to: opts.to,\n subject: invitationEmail.subject(opts),\n html: invitationEmail.render(opts),\n tags: [{ name: \"type\", value: \"invitation\" }],\n });\n}\n\n/**\n * Send a payment receipt with billing period and PDF download URL.\n */\nexport async function sendReceiptEmail(\n opts: { to: string } & ReceiptEmailProps,\n): Promise<SendResult> {\n return send({\n to: opts.to,\n subject: receiptEmail.subject(opts),\n html: receiptEmail.render(opts),\n tags: [{ name: \"type\", value: \"receipt\" }],\n });\n}\n","/**\n * Console email provider — prints email details to stdout.\n * Perfect for local development and testing without API keys.\n */\n\nimport type { EmailProvider, SendOptions, SendResult } from \"../provider\";\n\nlet counter = 0;\n\nexport class ConsoleEmailProvider implements EmailProvider {\n readonly name = \"console\" as const;\n\n async send(opts: SendOptions): Promise<SendResult> {\n counter += 1;\n const id = `console_${Date.now()}_${counter}`;\n const recipients = Array.isArray(opts.to) ? opts.to.join(\", \") : opts.to;\n const separator = \"─\".repeat(60);\n\n const lines = [\n `\\n${separator}`,\n `📧 Email (console provider)`,\n `${separator}`,\n `From: ${opts.from}`,\n `To: ${recipients}`,\n `Subject: ${opts.subject}`,\n ];\n\n if (opts.replyTo) {\n lines.push(`Reply-To: ${opts.replyTo}`);\n }\n\n if (opts.tags?.length) {\n const tagStr = opts.tags.map((t) => `${t.name}=${t.value}`).join(\", \");\n lines.push(`Tags: ${tagStr}`);\n }\n\n lines.push(`ID: ${id}`);\n lines.push(`${separator}\\n`);\n\n process.stdout.write(lines.join(\"\\n\"));\n\n return { id };\n }\n}\n","/**\n * Nodemailer email provider — SMTP-based, self-hosted fallback.\n * Requires SMTP_HOST (+ optional SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE).\n */\n\nimport type { EmailProvider, SendOptions, SendResult } from \"../provider\";\n\nexport interface NodemailerConfig {\n host: string;\n port: number;\n secure: boolean;\n auth?:\n | {\n user: string;\n pass: string;\n }\n | undefined;\n}\n\nexport class NodemailerEmailProvider implements EmailProvider {\n readonly name = \"nodemailer\" as const;\n private config: NodemailerConfig;\n private transporter: unknown;\n\n constructor(config: NodemailerConfig) {\n if (!config.host) {\n throw new Error(\"SMTP_HOST is required for Nodemailer provider\");\n }\n this.config = config;\n }\n\n private async getTransporter() {\n if (this.transporter) return this.transporter;\n\n // Lazy import to avoid requiring nodemailer when using other providers\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const nodemailer = await (import(\"nodemailer\" as string) as Promise<{\n createTransport(opts: Record<string, unknown>): {\n sendMail(opts: Record<string, unknown>): Promise<{ messageId: string }>;\n };\n }>);\n this.transporter = nodemailer.createTransport({\n host: this.config.host,\n port: this.config.port,\n secure: this.config.secure,\n ...(this.config.auth ? { auth: this.config.auth } : {}),\n });\n return this.transporter;\n }\n\n async send(opts: SendOptions): Promise<SendResult> {\n const transporter = (await this.getTransporter()) as {\n sendMail(opts: Record<string, unknown>): Promise<{ messageId: string }>;\n };\n\n const info = await transporter.sendMail({\n from: opts.from,\n to: Array.isArray(opts.to) ? opts.to.join(\", \") : opts.to,\n subject: opts.subject,\n html: opts.html,\n ...(opts.replyTo ? { replyTo: opts.replyTo } : {}),\n ...(opts.tags?.length\n ? {\n headers: {\n \"X-Email-Tags\": opts.tags.map((t) => `${t.name}=${t.value}`).join(\", \"),\n },\n }\n : {}),\n });\n\n return { id: info.messageId };\n }\n}\n","/**\n * Resend email provider — production-grade transactional email.\n * Requires RESEND_API_KEY environment variable.\n */\n\nimport { Resend } from \"resend\";\nimport type { EmailProvider, SendOptions, SendResult } from \"../provider\";\n\nexport class ResendEmailProvider implements EmailProvider {\n readonly name = \"resend\" as const;\n private client: Resend;\n\n constructor(apiKey: string) {\n if (!apiKey) {\n throw new Error(\"RESEND_API_KEY is required for Resend provider\");\n }\n this.client = new Resend(apiKey);\n }\n\n async send(opts: SendOptions): Promise<SendResult> {\n const { data, error } = await this.client.emails.send({\n from: opts.from,\n to: Array.isArray(opts.to) ? opts.to : [opts.to],\n subject: opts.subject,\n html: opts.html,\n ...(opts.replyTo ? { replyTo: opts.replyTo } : {}),\n ...(opts.tags ? { tags: opts.tags } : {}),\n });\n\n if (error) {\n throw new Error(`Resend send failed: ${error.message}`);\n }\n\n return { id: data?.id ?? \"unknown\" };\n }\n}\n","/**\n * @nebutra/email — Provider abstraction\n *\n * Supports multiple email providers via auto-detection:\n * 1. EMAIL_PROVIDER env var (explicit)\n * 2. RESEND_API_KEY → Resend\n * 3. SMTP_HOST → Nodemailer\n * 4. Fallback → Console (dev/test)\n */\n\n// ── Interface ────────────────────────────────────────────────────────────────\n\nexport interface SendOptions {\n to: string | string[];\n subject: string;\n html: string;\n from: string;\n replyTo?: string;\n tags?: { name: string; value: string }[];\n}\n\nexport interface SendResult {\n id: string;\n}\n\nexport interface EmailProvider {\n readonly name: string;\n send(opts: SendOptions): Promise<SendResult>;\n}\n\n// ── Provider types ───────────────────────────────────────────────────────────\n\nexport type EmailProviderType = \"resend\" | \"nodemailer\" | \"console\";\n\n// ── Factory ──────────────────────────────────────────────────────────────────\n\nimport { ConsoleEmailProvider } from \"./providers/console\";\nimport { NodemailerEmailProvider } from \"./providers/nodemailer\";\nimport { ResendEmailProvider } from \"./providers/resend\";\n\nlet cachedProvider: EmailProvider | undefined;\n\nfunction detectProviderType(): EmailProviderType {\n const explicit = process.env.EMAIL_PROVIDER;\n if (explicit === \"resend\" || explicit === \"nodemailer\" || explicit === \"console\") {\n return explicit;\n }\n\n if (process.env.RESEND_API_KEY) return \"resend\";\n if (process.env.SMTP_HOST) return \"nodemailer\";\n return \"console\";\n}\n\nfunction createProvider(type: EmailProviderType): EmailProvider {\n switch (type) {\n case \"resend\":\n return new ResendEmailProvider(process.env.RESEND_API_KEY!);\n case \"nodemailer\":\n return new NodemailerEmailProvider({\n host: process.env.SMTP_HOST!,\n port: Number(process.env.SMTP_PORT ?? \"587\"),\n secure: process.env.SMTP_SECURE === \"true\",\n auth: process.env.SMTP_USER\n ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS ?? \"\" }\n : undefined,\n });\n case \"console\":\n return new ConsoleEmailProvider();\n }\n}\n\nexport function getEmailProvider(): EmailProvider {\n if (cachedProvider) return cachedProvider;\n const type = detectProviderType();\n cachedProvider = createProvider(type);\n return cachedProvider;\n}\n\nexport function resetEmailProvider(): void {\n cachedProvider = undefined;\n}\n","/**\n * InvitationEmail — workspace member invitation with role and expiry.\n *\n * react-email is not installed; this module exposes the same\n * `{ subject, preview, render }` contract using a plain template literal so the\n * catalog can be migrated to React Email later without changing call sites.\n */\n\nimport { colors } from \"@nebutra/brand/metadata\";\nimport { baseLayout, escapeHtml, palette } from \"./_layout\";\n\nexport interface InvitationEmailProps {\n inviterName: string;\n organizationName: string;\n role: string;\n acceptUrl: string;\n expiresAt: string;\n brandName: string;\n}\n\nexport function subject(props: InvitationEmailProps): string {\n return `${props.inviterName} invited you to join ${props.organizationName}`;\n}\n\nexport function preview(props: InvitationEmailProps): string {\n return `You've been invited to ${props.organizationName} as ${props.role}`;\n}\n\nexport function render(props: InvitationEmailProps): string {\n const inviterName = escapeHtml(props.inviterName);\n const organizationName = escapeHtml(props.organizationName);\n const role = escapeHtml(props.role);\n const acceptUrl = escapeHtml(props.acceptUrl);\n const expiresAt = escapeHtml(props.expiresAt);\n const brandName = escapeHtml(props.brandName);\n\n const body = `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:${palette.heading};\">You're invited to ${organizationName}</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;\">\n <strong>${inviterName}</strong> has invited you to collaborate on\n <strong>${organizationName}</strong>.\n </p>\n <p style=\"margin:0 0 24px;font-size:14px;color:${palette.body};\">\n Role:\n <span style=\"display:inline-block;background:#e0f2fe;color:${colors.primary[\"500\"]};padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin-left:6px;\">\n ${role}\n </span>\n </p>\n <a href=\"${acceptUrl}\" style=\"display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n Accept invitation →\n </a>\n <p style=\"margin:0 0 12px;font-size:13px;color:${palette.muted};\">\n This invitation expires on <strong>${expiresAt}</strong>.\n </p>\n <p style=\"margin:0;font-size:13px;color:${palette.muted};\">\n Sent by ${brandName} on behalf of ${organizationName}. If you weren't expecting this, you can\n safely ignore the message.\n </p>\n `;\n\n return baseLayout({\n brandName: props.brandName,\n preview: preview(props),\n body,\n });\n}\n\nexport const invitationEmail = { subject, preview, render } as const;\n","import { colors } from \"@nebutra/brand/metadata\";\nimport { getBrandOrigin } from \"@nebutra/brand/metadata-helpers\";\n/**\n * Shared HTML layout for the React Email-style template catalog.\n *\n * NOTE: react-email is NOT installed in this workspace. This module ships a\n * plain template literal layout instead so the templates remain framework-free\n * and can be rendered without a React runtime. The contract still mirrors a\n * future React Email migration: each template module exports\n * `{ subject, preview, render }` and the renderer returns a complete HTML\n * document.\n */\n\nexport interface RenderedEmail {\n html: string;\n subject: string;\n preview: string;\n}\n\n/**\n * Email clients do not load app CSS variables. These are identity-locked hex\n * snapshots (VI brand-primary header), not product-chrome skins. Keep in sync with\n * brand assets — do not invent per-template blues.\n */\nconst BRAND_HEADER = colors.primary[\"500\"];\nconst COLOR_BG = \"#f8fafc\";\nconst COLOR_BORDER = \"#e2e8f0\";\nconst COLOR_HEADING = \"#0f172a\";\nconst COLOR_BODY = \"#475569\";\nconst COLOR_MUTED = \"#94a3b8\";\n\nexport interface BaseLayoutOptions {\n brandName: string;\n preview: string;\n body: string;\n}\n\n/**\n * Wrap a body fragment with the standard brand-style transactional shell.\n */\nexport function baseLayout({ brandName, preview, body }: BaseLayoutOptions): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"x-apple-disable-message-reformatting\" />\n <title>${escapeHtml(brandName)}</title>\n</head>\n<body style=\"margin:0;padding:0;background:${COLOR_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;\">\n <div style=\"display:none;max-height:0;overflow:hidden;\">${escapeHtml(preview)} ‌ </div>\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"background:${COLOR_BG};\">\n <tr><td align=\"center\" style=\"padding:40px 16px;\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"background:#ffffff;border-radius:12px;border:1px solid ${COLOR_BORDER};overflow:hidden;max-width:600px;width:100%;\">\n <tr><td style=\"background:${BRAND_HEADER};padding:32px 40px;\">\n <h1 style=\"margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;\">${escapeHtml(brandName)}</h1>\n </td></tr>\n <tr><td style=\"padding:40px;\">\n ${body}\n </td></tr>\n <tr><td style=\"padding:24px 40px;border-top:1px solid ${COLOR_BORDER};background:${COLOR_BG};\">\n <p style=\"margin:0;font-size:12px;color:${COLOR_MUTED};text-align:center;\">\n © ${new Date().getFullYear()} ${escapeHtml(brandName)} ·\n <a href=\"${getBrandOrigin(\"landing\")}/privacy\" style=\"color:${COLOR_MUTED};\">Privacy</a> ·\n <a href=\"${getBrandOrigin(\"landing\")}/unsubscribe\" style=\"color:${COLOR_MUTED};\">Unsubscribe</a>\n </p>\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>`;\n}\n\nexport const palette = {\n /** @deprecated alias of solid identity header — not a product skin */\n gradient: BRAND_HEADER,\n header: BRAND_HEADER,\n background: COLOR_BG,\n border: COLOR_BORDER,\n heading: COLOR_HEADING,\n body: COLOR_BODY,\n muted: COLOR_MUTED,\n} as const;\n\n/**\n * Conservative HTML escape for user-supplied template props.\n *\n * Templates accept structured props from trusted backend code, but escaping is\n * still required because some props (e.g. organization names, invoice numbers)\n * may originate from end-user input.\n */\nexport function escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n","/**\n * PasswordResetEmail — secure password reset link with expiry.\n *\n * react-email is not installed; this module exposes the same\n * `{ subject, preview, render }` contract using a plain template literal so the\n * catalog can be migrated to React Email later without changing call sites.\n */\n\nimport { baseLayout, escapeHtml, palette } from \"./_layout\";\n\nexport interface PasswordResetEmailProps {\n userName: string;\n resetUrl: string;\n expiresInMinutes: number;\n brandName: string;\n}\n\nexport function subject(props: PasswordResetEmailProps): string {\n return `Reset your ${props.brandName} password`;\n}\n\nexport function preview(props: PasswordResetEmailProps): string {\n return `Reset link expires in ${props.expiresInMinutes} minutes`;\n}\n\nexport function render(props: PasswordResetEmailProps): string {\n const userName = escapeHtml(props.userName);\n const resetUrl = escapeHtml(props.resetUrl);\n const brandName = escapeHtml(props.brandName);\n const expires = String(props.expiresInMinutes);\n\n const body = `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:${palette.heading};\">Reset your password</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;\">\n Hi ${userName}, we received a request to reset the password on your ${brandName} account.\n Click the button below to choose a new one.\n </p>\n <a href=\"${resetUrl}\" style=\"display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n Reset password →\n </a>\n <p style=\"margin:0 0 16px;font-size:14px;color:${palette.body};line-height:1.6;\">\n This link expires in <strong>${expires} minutes</strong>. If it's expired, request a new one\n from the sign-in page.\n </p>\n <p style=\"margin:0;font-size:13px;color:${palette.muted};line-height:1.5;\">\n If this wasn't you, you can safely ignore this email — your password won't change unless\n you click the link above. For added safety, review recent sign-ins on your account.\n </p>\n `;\n\n return baseLayout({\n brandName: props.brandName,\n preview: preview(props),\n body,\n });\n}\n\nexport const passwordResetEmail = { subject, preview, render } as const;\n","/**\n * ReceiptEmail — payment confirmation with billing period and invoice download.\n *\n * react-email is not installed; this module exposes the same\n * `{ subject, preview, render }` contract using a plain template literal so the\n * catalog can be migrated to React Email later without changing call sites.\n */\n\nimport { baseLayout, escapeHtml, palette } from \"./_layout\";\n\nexport interface ReceiptEmailProps {\n customerName: string;\n invoiceNumber: string;\n amount: string;\n currency: string;\n periodStart: string;\n periodEnd: string;\n downloadUrl: string;\n brandName: string;\n}\n\nexport function subject(props: ReceiptEmailProps): string {\n return `Receipt for ${props.invoiceNumber}`;\n}\n\nexport function preview(props: ReceiptEmailProps): string {\n return `Payment of ${props.amount} ${props.currency} received`;\n}\n\nexport function render(props: ReceiptEmailProps): string {\n const customerName = escapeHtml(props.customerName);\n const invoiceNumber = escapeHtml(props.invoiceNumber);\n const amount = escapeHtml(props.amount);\n const currency = escapeHtml(props.currency);\n const periodStart = escapeHtml(props.periodStart);\n const periodEnd = escapeHtml(props.periodEnd);\n const downloadUrl = escapeHtml(props.downloadUrl);\n const brandName = escapeHtml(props.brandName);\n\n const body = `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:${palette.heading};\">Payment received ✓</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;\">\n Hi ${customerName}, thank you for your payment. Here's a summary of your latest invoice from\n ${brandName}.\n </p>\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:0 0 24px;border-collapse:collapse;border:1px solid ${palette.border};border-radius:8px;overflow:hidden;\">\n <tr>\n <td style=\"padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:13px;color:${palette.muted};\">Invoice number</td>\n <td style=\"padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:14px;color:${palette.heading};text-align:right;font-weight:600;\">${invoiceNumber}</td>\n </tr>\n <tr>\n <td style=\"padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:13px;color:${palette.muted};\">Billing period</td>\n <td style=\"padding:12px 16px;border-bottom:1px solid ${palette.border};font-size:14px;color:${palette.heading};text-align:right;\">${periodStart} — ${periodEnd}</td>\n </tr>\n <tr>\n <td style=\"padding:12px 16px;font-size:13px;color:${palette.muted};\">Amount paid</td>\n <td style=\"padding:12px 16px;font-size:14px;color:#16a34a;text-align:right;font-weight:700;\">${amount} ${currency}</td>\n </tr>\n </table>\n <a href=\"${downloadUrl}\" style=\"display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n Download PDF receipt →\n </a>\n <p style=\"margin:0;font-size:13px;color:${palette.muted};\">\n Keep this receipt for your records. Questions about this charge? Reply to this email and the\n ${brandName} billing team will help.\n </p>\n `;\n\n return baseLayout({\n brandName: props.brandName,\n preview: preview(props),\n body,\n });\n}\n\nexport const receiptEmail = { subject, preview, render } as const;\n","/**\n * WelcomeEmail — onboarding welcome message for new accounts.\n *\n * react-email is not installed; this module exposes the same\n * `{ subject, preview, render }` contract using a plain template literal so the\n * catalog can be migrated to React Email later without changing call sites.\n */\n\nimport { baseLayout, escapeHtml, palette } from \"./_layout\";\n\nexport interface WelcomeEmailProps {\n userName: string;\n loginUrl: string;\n brandName: string;\n}\n\nexport function subject(props: WelcomeEmailProps): string {\n return `Welcome to ${props.brandName}`;\n}\n\nexport function preview(_props: WelcomeEmailProps): string {\n return \"Get started with your account\";\n}\n\nexport function render(props: WelcomeEmailProps): string {\n const userName = escapeHtml(props.userName);\n const loginUrl = escapeHtml(props.loginUrl);\n const brandName = escapeHtml(props.brandName);\n\n const body = `\n <h2 style=\"margin:0 0 16px;font-size:22px;color:${palette.heading};\">Welcome, ${userName}!</h2>\n <p style=\"margin:0 0 16px;font-size:15px;color:${palette.body};line-height:1.6;\">\n We're glad to have you with ${brandName}. Your account is ready — sign in to\n explore the dashboard, set up your workspace, and invite the rest of your team.\n </p>\n <a href=\"${loginUrl}\" style=\"display:inline-block;background:${palette.gradient};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;\">\n Get Started →\n </a>\n <p style=\"margin:0;font-size:13px;color:${palette.muted};\">\n Need help? Reply to this email and a real human from the ${brandName} team will get back to you.\n </p>\n `;\n\n return baseLayout({\n brandName: props.brandName,\n preview: preview(props),\n body,\n });\n}\n\nexport const welcomeEmail = { subject, preview, render } as const;\n","/**\n * React Email-style template catalog.\n *\n * Each template module exports `{ subject, preview, render }` plus a typed\n * Props interface. They render to a complete HTML document via plain template\n * literals (react-email is not installed in this workspace; see AGENTS.md).\n *\n * The legacy `send*Email` helpers in `../index.ts` remain the canonical way to\n * dispatch transactional email. These template modules are the new React-Email\n * shaped surface intended for the mail-preview app and future migration.\n */\n\nexport type { RenderedEmail } from \"./_layout\";\nexport {\n type InvitationEmailProps,\n invitationEmail,\n preview as invitationEmailPreview,\n render as renderInvitationEmail,\n subject as invitationEmailSubject,\n} from \"./invitation\";\nexport {\n type PasswordResetEmailProps,\n passwordResetEmail,\n preview as passwordResetEmailPreview,\n render as renderPasswordResetEmail,\n subject as passwordResetEmailSubject,\n} from \"./password-reset\";\n\nexport {\n preview as receiptEmailPreview,\n type ReceiptEmailProps,\n receiptEmail,\n render as renderReceiptEmail,\n subject as receiptEmailSubject,\n} from \"./receipt\";\nexport {\n preview as welcomeEmailPreview,\n render as renderWelcomeEmail,\n subject as welcomeEmailSubject,\n type WelcomeEmailProps,\n welcomeEmail,\n} from \"./welcome\";\n\nimport { invitationEmail } from \"./invitation\";\nimport { passwordResetEmail } from \"./password-reset\";\nimport { receiptEmail } from \"./receipt\";\nimport { welcomeEmail } from \"./welcome\";\n\n/**\n * Stable shape of a registered template entry for the React Email-style\n * catalog. Distinct from `EMAIL_TEMPLATE_CATALOG` in `../index.ts` which tracks\n * the legacy `send*Email` helper surface.\n */\nexport interface ReactEmailTemplate<P> {\n id: string;\n label: string;\n description: string;\n fileName: string;\n subject: (props: P) => string;\n preview: (props: P) => string;\n render: (props: P) => string;\n}\n\nexport const REACT_EMAIL_TEMPLATES = {\n welcome: {\n id: \"welcome-react\",\n label: \"Welcome (React Email)\",\n description: \"Account onboarding welcome message\",\n fileName: \"welcome-react-email.html\",\n ...welcomeEmail,\n },\n passwordReset: {\n id: \"password-reset\",\n label: \"Password Reset\",\n description: \"Secure password reset link with expiry\",\n fileName: \"password-reset-email.html\",\n ...passwordResetEmail,\n },\n invitation: {\n id: \"invitation\",\n label: \"Invitation\",\n description: \"Workspace invitation with role and expiry\",\n fileName: \"invitation-email.html\",\n ...invitationEmail,\n },\n receipt: {\n id: \"receipt\",\n label: \"Receipt\",\n description: \"Payment receipt with billing period and PDF link\",\n fileName: \"receipt-email.html\",\n ...receiptEmail,\n },\n} as const;\n"],"mappings":"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","names":["colors","getBrandOrigin","colors","preview","colors","subject","preview","render","subject","preview","render","subject","preview","render","baseLayout","colors","getBrandOrigin"]}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/email",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Multi-provider transactional email (Resend, Nodemailer, Console)",
|
|
5
5
|
"private": false,
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"nebutra": {
|
|
9
9
|
"featureId": "email",
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"resend": "^4.0.1"
|
|
21
|
+
"resend": "^4.0.1",
|
|
22
|
+
"@nebutra/brand": "0.1.2"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
|
-
"nodemailer": "^
|
|
25
|
+
"nodemailer": "^9.0.1"
|
|
25
26
|
},
|
|
26
27
|
"peerDependenciesMeta": {
|
|
27
28
|
"nodemailer": {
|
|
@@ -37,7 +37,7 @@ describe("ConsoleEmailProvider", () => {
|
|
|
37
37
|
to: "user@example.com",
|
|
38
38
|
subject: "Test Subject",
|
|
39
39
|
html: "<p>Hello</p>",
|
|
40
|
-
from: "noreply@nebutra.
|
|
40
|
+
from: "noreply@nebutra.com",
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
expect(result.id).toBeDefined();
|
|
@@ -50,13 +50,13 @@ describe("ConsoleEmailProvider", () => {
|
|
|
50
50
|
to: "user@example.com",
|
|
51
51
|
subject: "Test Subject",
|
|
52
52
|
html: "<p>Hello</p>",
|
|
53
|
-
from: "Nebutra <noreply@nebutra.
|
|
53
|
+
from: "Nebutra <noreply@nebutra.com>",
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
const output = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("");
|
|
57
57
|
expect(output).toContain("user@example.com");
|
|
58
58
|
expect(output).toContain("Test Subject");
|
|
59
|
-
expect(output).toContain("noreply@nebutra.
|
|
59
|
+
expect(output).toContain("noreply@nebutra.com");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("handles array recipients", async () => {
|
|
@@ -64,7 +64,7 @@ describe("ConsoleEmailProvider", () => {
|
|
|
64
64
|
to: ["a@test.com", "b@test.com"],
|
|
65
65
|
subject: "Multi",
|
|
66
66
|
html: "<p>Hi</p>",
|
|
67
|
-
from: "noreply@nebutra.
|
|
67
|
+
from: "noreply@nebutra.com",
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
const output = stdoutSpy.mock.calls.map((c: unknown[]) => c[0]).join("");
|
|
@@ -77,7 +77,7 @@ describe("ConsoleEmailProvider", () => {
|
|
|
77
77
|
to: "user@example.com",
|
|
78
78
|
subject: "Tagged",
|
|
79
79
|
html: "<p>Hi</p>",
|
|
80
|
-
from: "noreply@nebutra.
|
|
80
|
+
from: "noreply@nebutra.com",
|
|
81
81
|
tags: [{ name: "type", value: "welcome" }],
|
|
82
82
|
});
|
|
83
83
|
|
package/src/index.ts
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Environment variables:
|
|
15
15
|
* EMAIL_PROVIDER — explicit provider override (optional)
|
|
16
|
-
* EMAIL_FROM — verified sender (e.g.
|
|
16
|
+
* EMAIL_FROM — verified sender (e.g. getBrandMailFrom() example)
|
|
17
17
|
* RESEND_API_KEY — for Resend provider
|
|
18
18
|
* SMTP_HOST — for Nodemailer provider (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { brand, colors } from "@nebutra/brand/metadata";
|
|
22
|
+
import { getBrandMailFrom, getBrandOrigin } from "@nebutra/brand/metadata-helpers";
|
|
21
23
|
import { getEmailProvider, type SendResult } from "./provider";
|
|
22
24
|
|
|
23
25
|
export type { EmailProvider, EmailProviderType, SendOptions, SendResult } from "./provider";
|
|
@@ -26,7 +28,7 @@ export { ConsoleEmailProvider } from "./providers/console";
|
|
|
26
28
|
export { NodemailerEmailProvider } from "./providers/nodemailer";
|
|
27
29
|
export { ResendEmailProvider } from "./providers/resend";
|
|
28
30
|
|
|
29
|
-
const FROM = process.env.EMAIL_FROM ??
|
|
31
|
+
const FROM = process.env.EMAIL_FROM ?? getBrandMailFrom();
|
|
30
32
|
|
|
31
33
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
32
34
|
|
|
@@ -60,6 +62,13 @@ export const EMAIL_TEMPLATE_CATALOG = [
|
|
|
60
62
|
sendHelper: "sendMagicLinkEmail",
|
|
61
63
|
fileName: "magic-link-email.html",
|
|
62
64
|
},
|
|
65
|
+
{
|
|
66
|
+
id: "email-change",
|
|
67
|
+
label: "Email Change",
|
|
68
|
+
description: "Confirm a new account email address",
|
|
69
|
+
sendHelper: "sendEmailChangeEmail",
|
|
70
|
+
fileName: "email-change-email.html",
|
|
71
|
+
},
|
|
63
72
|
{
|
|
64
73
|
id: "contact-form-received",
|
|
65
74
|
label: "Contact Form Received",
|
|
@@ -129,7 +138,7 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
129
138
|
<meta charset="UTF-8" />
|
|
130
139
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
131
140
|
<meta name="x-apple-disable-message-reformatting" />
|
|
132
|
-
<title
|
|
141
|
+
<title>${brand.name}</title>
|
|
133
142
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
134
143
|
</head>
|
|
135
144
|
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
@@ -138,8 +147,8 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
138
147
|
<tr><td align="center" style="padding:40px 16px;">
|
|
139
148
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;overflow:hidden;max-width:600px;width:100%;">
|
|
140
149
|
<!-- Header -->
|
|
141
|
-
<tr><td style="background
|
|
142
|
-
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;"
|
|
150
|
+
<tr><td style="background:${colors.primary["500"]};padding:32px 40px;">
|
|
151
|
+
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${brand.name}</h1>
|
|
143
152
|
</td></tr>
|
|
144
153
|
<!-- Body -->
|
|
145
154
|
<tr><td style="padding:40px;">
|
|
@@ -148,9 +157,9 @@ function baseLayout(content: string, previewText = ""): string {
|
|
|
148
157
|
<!-- Footer -->
|
|
149
158
|
<tr><td style="padding:24px 40px;border-top:1px solid #e2e8f0;background:#f8fafc;">
|
|
150
159
|
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">
|
|
151
|
-
© ${new Date().getFullYear()}
|
|
152
|
-
<a href="
|
|
153
|
-
<a href="
|
|
160
|
+
© ${new Date().getFullYear()} ${brand.nameFullEn} ·
|
|
161
|
+
<a href="${getBrandOrigin("landing")}/privacy" style="color:#94a3b8;">Privacy</a> ·
|
|
162
|
+
<a href="${getBrandOrigin("landing")}/unsubscribe" style="color:#94a3b8;">Unsubscribe</a>
|
|
154
163
|
</p>
|
|
155
164
|
</td></tr>
|
|
156
165
|
</table>
|
|
@@ -171,28 +180,28 @@ export async function sendWelcomeEmail(opts: {
|
|
|
171
180
|
orgName: string;
|
|
172
181
|
dashboardUrl?: string;
|
|
173
182
|
}): Promise<SendResult> {
|
|
174
|
-
const dashboardUrl = opts.dashboardUrl ?? "
|
|
183
|
+
const dashboardUrl = opts.dashboardUrl ?? getBrandOrigin("app");
|
|
175
184
|
|
|
176
185
|
const html = baseLayout(
|
|
177
186
|
`
|
|
178
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to
|
|
187
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to ${brand.name}, ${opts.firstName}!</h2>
|
|
179
188
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
180
189
|
Your workspace <strong>${opts.orgName}</strong> is ready. You can now invite team members,
|
|
181
|
-
create API keys, and start building with the
|
|
190
|
+
create API keys, and start building with the ${brand.name} platform.
|
|
182
191
|
</p>
|
|
183
|
-
<a href="${dashboardUrl}" style="display:inline-block;background
|
|
192
|
+
<a href="${dashboardUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
184
193
|
Open Dashboard →
|
|
185
194
|
</a>
|
|
186
195
|
<p style="margin:0;font-size:13px;color:#94a3b8;">
|
|
187
|
-
If you have questions, reply to this email or visit our <a href="
|
|
196
|
+
If you have questions, reply to this email or visit our <a href="${getBrandOrigin("docs")}" style="color:${colors.primary["500"]};">documentation</a>.
|
|
188
197
|
</p>
|
|
189
198
|
`,
|
|
190
|
-
`Welcome to
|
|
199
|
+
`Welcome to ${brand.name} — ${opts.orgName} is ready`,
|
|
191
200
|
);
|
|
192
201
|
|
|
193
202
|
return send({
|
|
194
203
|
to: opts.to,
|
|
195
|
-
subject: `Welcome to
|
|
204
|
+
subject: `Welcome to ${brand.name} — ${opts.orgName} is ready`,
|
|
196
205
|
html,
|
|
197
206
|
tags: [{ name: "type", value: "welcome" }],
|
|
198
207
|
});
|
|
@@ -204,9 +213,19 @@ export async function sendWelcomeEmail(opts: {
|
|
|
204
213
|
export async function sendOrderConfirmationEmail(opts: {
|
|
205
214
|
to: string;
|
|
206
215
|
orderId: string;
|
|
216
|
+
/** Order total in minor units (integer cents). */
|
|
207
217
|
totalAmount: number;
|
|
218
|
+
/** ISO 4217 currency code (default "USD"). */
|
|
219
|
+
currency?: string;
|
|
220
|
+
/** BCP 47 locale tag (default "en-US"). */
|
|
221
|
+
locale?: string;
|
|
208
222
|
items: Array<{ productId: string; quantity: number | string }>;
|
|
209
223
|
}): Promise<SendResult> {
|
|
224
|
+
const formattedTotal = new Intl.NumberFormat(opts.locale ?? "en-US", {
|
|
225
|
+
style: "currency",
|
|
226
|
+
currency: opts.currency ?? "USD",
|
|
227
|
+
}).format(opts.totalAmount / 100);
|
|
228
|
+
|
|
210
229
|
const itemsHtml = opts.items
|
|
211
230
|
.map(
|
|
212
231
|
(item) =>
|
|
@@ -221,13 +240,13 @@ export async function sendOrderConfirmationEmail(opts: {
|
|
|
221
240
|
`
|
|
222
241
|
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Order Confirmed!</h2>
|
|
223
242
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
224
|
-
Thank you for your order <strong>#${opts.orderId}</strong>. We've received your payment of
|
|
225
|
-
<strong
|
|
243
|
+
Thank you for your order <strong>#${opts.orderId}</strong>. We've received your payment of
|
|
244
|
+
<strong>${formattedTotal}</strong> and your order is now being processed.
|
|
226
245
|
</p>
|
|
227
246
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px;border-collapse:collapse;">
|
|
228
247
|
${itemsHtml}
|
|
229
248
|
</table>
|
|
230
|
-
<a href="
|
|
249
|
+
<a href="${getBrandOrigin("app")}/orders/${opts.orderId}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:15px;font-weight:600;margin:0 0 24px;">
|
|
231
250
|
View Order Details →
|
|
232
251
|
</a>
|
|
233
252
|
`,
|
|
@@ -251,11 +270,11 @@ export async function sendMagicLinkEmail(opts: {
|
|
|
251
270
|
}): Promise<SendResult> {
|
|
252
271
|
const html = baseLayout(
|
|
253
272
|
`
|
|
254
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Sign in to
|
|
273
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Sign in to ${brand.name}</h2>
|
|
255
274
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
256
275
|
Click the button below to sign in securely. No password required.
|
|
257
276
|
</p>
|
|
258
|
-
<a href="${opts.magicLinkUrl}" style="display:inline-block;background
|
|
277
|
+
<a href="${opts.magicLinkUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;">
|
|
259
278
|
✨ Sign In Automatically
|
|
260
279
|
</a>
|
|
261
280
|
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.4;">
|
|
@@ -263,17 +282,53 @@ export async function sendMagicLinkEmail(opts: {
|
|
|
263
282
|
This link expires in 15 minutes.
|
|
264
283
|
</p>
|
|
265
284
|
`,
|
|
266
|
-
|
|
285
|
+
`Click here to sign in to your ${brand.name} account`,
|
|
267
286
|
);
|
|
268
287
|
|
|
269
288
|
return send({
|
|
270
289
|
to: opts.to,
|
|
271
|
-
subject:
|
|
290
|
+
subject: `Sign in to ${brand.name} ✨`,
|
|
272
291
|
html,
|
|
273
292
|
tags: [{ name: "type", value: "magic_link" }],
|
|
274
293
|
});
|
|
275
294
|
}
|
|
276
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Email-change verification sent to the *new* address (TODO #126).
|
|
298
|
+
* Confirming the link flips the account email.
|
|
299
|
+
*/
|
|
300
|
+
export async function sendEmailChangeEmail(opts: {
|
|
301
|
+
to: string;
|
|
302
|
+
confirmUrl: string;
|
|
303
|
+
/** Optional display name for the new address (defaults to `to`). */
|
|
304
|
+
newEmail?: string;
|
|
305
|
+
}): Promise<SendResult> {
|
|
306
|
+
const recipientEmail = opts.newEmail ?? opts.to;
|
|
307
|
+
const html = baseLayout(
|
|
308
|
+
`
|
|
309
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Confirm your new email address</h2>
|
|
310
|
+
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
311
|
+
We received a request to change the email on your ${brand.name} account to
|
|
312
|
+
<strong>${recipientEmail}</strong>. Click the button below to confirm. This link expires in 1 hour.
|
|
313
|
+
</p>
|
|
314
|
+
<a href="${opts.confirmUrl}" style="display:inline-block;background:${colors.primary["500"]};color:#ffffff;text-decoration:none;border-radius:8px;padding:12px 24px;font-size:16px;font-weight:600;margin:0 0 24px;text-align:center;width:100%;max-width:280px;">
|
|
315
|
+
Confirm email change →
|
|
316
|
+
</a>
|
|
317
|
+
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.4;">
|
|
318
|
+
If you did not request this change you can safely ignore this email — your address will not be updated.
|
|
319
|
+
</p>
|
|
320
|
+
`,
|
|
321
|
+
"Confirm your new Nebutra email address",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return send({
|
|
325
|
+
to: opts.to,
|
|
326
|
+
subject: "Confirm your new Nebutra email address",
|
|
327
|
+
html,
|
|
328
|
+
tags: [{ name: "type", value: "email_change_verification" }],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
277
332
|
/**
|
|
278
333
|
* Contact Form Receipt (Sent to the User)
|
|
279
334
|
*/
|
|
@@ -293,7 +348,7 @@ export async function sendContactFormReceivedEmail(opts: {
|
|
|
293
348
|
We've received your request and our team will get back to you within 1-2 business days.
|
|
294
349
|
</p>
|
|
295
350
|
<p style="margin:0;font-size:15px;color:#475569;line-height:1.6;">
|
|
296
|
-
Best regards,<br/>The
|
|
351
|
+
Best regards,<br/>The ${brand.name} Team
|
|
297
352
|
</p>
|
|
298
353
|
`,
|
|
299
354
|
"We have received your message",
|
|
@@ -301,7 +356,7 @@ export async function sendContactFormReceivedEmail(opts: {
|
|
|
301
356
|
|
|
302
357
|
return send({
|
|
303
358
|
to: opts.to,
|
|
304
|
-
subject:
|
|
359
|
+
subject: `We've received your message - ${brand.name}`,
|
|
305
360
|
html,
|
|
306
361
|
tags: [{ name: "type", value: "contact_receipt" }],
|
|
307
362
|
});
|
|
@@ -320,7 +375,7 @@ export async function sendLicenseCreatedEmail(opts: {
|
|
|
320
375
|
}): Promise<SendResult> {
|
|
321
376
|
const html = baseLayout(
|
|
322
377
|
`
|
|
323
|
-
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to
|
|
378
|
+
<h2 style="margin:0 0 16px;font-size:22px;color:#0f172a;">Welcome to ${brand.name}-Sailor! 🚀</h2>
|
|
324
379
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
325
380
|
Hi ${opts.firstName}, your <strong>${opts.tier}</strong> license has been successfully generated.
|
|
326
381
|
</p>
|
|
@@ -328,24 +383,24 @@ export async function sendLicenseCreatedEmail(opts: {
|
|
|
328
383
|
Your official License Key is:
|
|
329
384
|
</p>
|
|
330
385
|
<div style="background:#0f172a;border-radius:8px;padding:16px 20px;margin:0 0 24px;overflow-x:auto;">
|
|
331
|
-
<code style="color
|
|
386
|
+
<code style="color:${colors.accent["500"]};font-family:'Courier New',monospace;font-size:13px;word-break:break-all;">${opts.licenseKey}</code>
|
|
332
387
|
</div>
|
|
333
388
|
<p style="margin:0 0 16px;font-size:15px;color:#475569;line-height:1.6;">
|
|
334
|
-
To unlock the premium capabilities of the
|
|
389
|
+
To unlock the premium capabilities of the ${brand.name} CLI locally, run the following command in your terminal:
|
|
335
390
|
</p>
|
|
336
391
|
<div style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:6px;padding:12px;margin:0 0 24px;">
|
|
337
392
|
<code style="color:#334155;font-family:'Courier New',monospace;font-size:13px;">nebutra license activate ${opts.licenseKey}</code>
|
|
338
393
|
</div>
|
|
339
394
|
<p style="margin:0;font-size:13px;color:#94a3b8;">
|
|
340
|
-
If you have questions, reply to this email or visit our <a href="
|
|
395
|
+
If you have questions, reply to this email or visit our <a href="${getBrandOrigin("docs")}" style="color:${colors.primary["500"]};">documentation</a>.
|
|
341
396
|
</p>
|
|
342
397
|
`,
|
|
343
|
-
|
|
398
|
+
`Your ${brand.name} License Key is Ready`,
|
|
344
399
|
);
|
|
345
400
|
|
|
346
401
|
return send({
|
|
347
402
|
to: opts.to,
|
|
348
|
-
subject: `Your
|
|
403
|
+
subject: `Your ${brand.name} License Key is Ready (${opts.tier})`,
|
|
349
404
|
html,
|
|
350
405
|
tags: [{ name: "type", value: "license_created" }],
|
|
351
406
|
});
|
package/src/templates/_layout.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { colors } from "@nebutra/brand/metadata";
|
|
2
|
+
import { getBrandOrigin } from "@nebutra/brand/metadata-helpers";
|
|
1
3
|
/**
|
|
2
4
|
* Shared HTML layout for the React Email-style template catalog.
|
|
3
5
|
*
|
|
@@ -15,7 +17,12 @@ export interface RenderedEmail {
|
|
|
15
17
|
preview: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Email clients do not load app CSS variables. These are identity-locked hex
|
|
22
|
+
* snapshots (VI brand-primary header), not product-chrome skins. Keep in sync with
|
|
23
|
+
* brand assets — do not invent per-template blues.
|
|
24
|
+
*/
|
|
25
|
+
const BRAND_HEADER = colors.primary["500"];
|
|
19
26
|
const COLOR_BG = "#f8fafc";
|
|
20
27
|
const COLOR_BORDER = "#e2e8f0";
|
|
21
28
|
const COLOR_HEADING = "#0f172a";
|
|
@@ -29,7 +36,7 @@ export interface BaseLayoutOptions {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/**
|
|
32
|
-
* Wrap a body fragment with the standard
|
|
39
|
+
* Wrap a body fragment with the standard brand-style transactional shell.
|
|
33
40
|
*/
|
|
34
41
|
export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): string {
|
|
35
42
|
return `<!DOCTYPE html>
|
|
@@ -45,7 +52,7 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
45
52
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:${COLOR_BG};">
|
|
46
53
|
<tr><td align="center" style="padding:40px 16px;">
|
|
47
54
|
<table width="600" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;border-radius:12px;border:1px solid ${COLOR_BORDER};overflow:hidden;max-width:600px;width:100%;">
|
|
48
|
-
<tr><td style="background:${
|
|
55
|
+
<tr><td style="background:${BRAND_HEADER};padding:32px 40px;">
|
|
49
56
|
<h1 style="margin:0;color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${escapeHtml(brandName)}</h1>
|
|
50
57
|
</td></tr>
|
|
51
58
|
<tr><td style="padding:40px;">
|
|
@@ -54,8 +61,8 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
54
61
|
<tr><td style="padding:24px 40px;border-top:1px solid ${COLOR_BORDER};background:${COLOR_BG};">
|
|
55
62
|
<p style="margin:0;font-size:12px;color:${COLOR_MUTED};text-align:center;">
|
|
56
63
|
© ${new Date().getFullYear()} ${escapeHtml(brandName)} ·
|
|
57
|
-
<a href="
|
|
58
|
-
<a href="
|
|
64
|
+
<a href="${getBrandOrigin("landing")}/privacy" style="color:${COLOR_MUTED};">Privacy</a> ·
|
|
65
|
+
<a href="${getBrandOrigin("landing")}/unsubscribe" style="color:${COLOR_MUTED};">Unsubscribe</a>
|
|
59
66
|
</p>
|
|
60
67
|
</td></tr>
|
|
61
68
|
</table>
|
|
@@ -66,7 +73,9 @@ export function baseLayout({ brandName, preview, body }: BaseLayoutOptions): str
|
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export const palette = {
|
|
69
|
-
|
|
76
|
+
/** @deprecated alias of solid identity header — not a product skin */
|
|
77
|
+
gradient: BRAND_HEADER,
|
|
78
|
+
header: BRAND_HEADER,
|
|
70
79
|
background: COLOR_BG,
|
|
71
80
|
border: COLOR_BORDER,
|
|
72
81
|
heading: COLOR_HEADING,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* catalog can be migrated to React Email later without changing call sites.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { colors } from "@nebutra/brand/metadata";
|
|
9
10
|
import { baseLayout, escapeHtml, palette } from "./_layout";
|
|
10
11
|
|
|
11
12
|
export interface InvitationEmailProps {
|
|
@@ -41,7 +42,7 @@ export function render(props: InvitationEmailProps): string {
|
|
|
41
42
|
</p>
|
|
42
43
|
<p style="margin:0 0 24px;font-size:14px;color:${palette.body};">
|
|
43
44
|
Role:
|
|
44
|
-
<span style="display:inline-block;background:#e0f2fe;color
|
|
45
|
+
<span style="display:inline-block;background:#e0f2fe;color:${colors.primary["500"]};padding:4px 10px;border-radius:999px;font-size:13px;font-weight:600;margin-left:6px;">
|
|
45
46
|
${role}
|
|
46
47
|
</span>
|
|
47
48
|
</p>
|