feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_SENDER: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
ZOHO_TOKEN: z.string().optional()
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import type { EmailResponse } from '../utils/types';
|
||||
import type { SendTemplateConfig } from './types';
|
||||
import { deliverEmail } from '../send';
|
||||
|
||||
/**
|
||||
* Send a single email using a template
|
||||
* This function validates fields, generates content, and sends the email
|
||||
*/
|
||||
export async function sendTemplateEmail<TSchema extends z.ZodType>(
|
||||
config: SendTemplateConfig<TSchema>
|
||||
): Promise<EmailResponse[]> {
|
||||
const content = config.template.render(config.fields);
|
||||
|
||||
return deliverEmail([
|
||||
{
|
||||
to: config.to,
|
||||
subject: config.template.subject,
|
||||
content,
|
||||
from: config.from ?? config.template.from,
|
||||
replyTo: config.replyTo ?? config.template.replyTo
|
||||
}
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import type { EmailTemplate } from './types';
|
||||
|
||||
/**
|
||||
* Internal registry that maps email IDs to templates
|
||||
*/
|
||||
class EmailRegistryClass {
|
||||
private emails = new Map<string, EmailTemplate>();
|
||||
|
||||
/**
|
||||
* Register an email template with an ID
|
||||
*/
|
||||
register<TSchema extends z.ZodType>(id: string, template: EmailTemplate<TSchema>): void {
|
||||
if (this.emails.has(id)) {
|
||||
console.warn(`Email template "${id}" is being registered multiple times. The last registration will be used.`);
|
||||
}
|
||||
this.emails.set(id, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an email template by ID
|
||||
*/
|
||||
get(id: string): EmailTemplate | undefined {
|
||||
return this.emails.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered email IDs
|
||||
*/
|
||||
getAllIds(): string[] {
|
||||
return Array.from(this.emails.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email ID exists
|
||||
*/
|
||||
has(id: string): boolean {
|
||||
return this.emails.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const EmailRegistry = new EmailRegistryClass();
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import type { EmailId } from '../utils/types';
|
||||
|
||||
/**
|
||||
* Base email template interface
|
||||
*/
|
||||
export interface EmailTemplate<TSchema extends z.ZodType = z.ZodType> {
|
||||
subject: string;
|
||||
schema: TSchema;
|
||||
render: (fields: z.infer<TSchema>) => string;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email definition with ID for registry
|
||||
*/
|
||||
export interface EmailDefinition<TSchema extends z.ZodType = z.ZodType> {
|
||||
id: EmailId;
|
||||
template: EmailTemplate<TSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for sending an email
|
||||
*/
|
||||
export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
|
||||
to: string;
|
||||
fields: z.infer<TSchema>;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal send configuration with template
|
||||
*/
|
||||
export interface SendTemplateConfig<TSchema extends z.ZodType = z.ZodType> extends SendConfig<TSchema> {
|
||||
template: EmailTemplate<TSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for defining an email template
|
||||
*/
|
||||
export interface DefineEmailConfig<TSchema extends z.ZodType = z.ZodType> {
|
||||
id: EmailId;
|
||||
subject: string;
|
||||
schema: TSchema;
|
||||
render: (fields: z.infer<TSchema>) => string;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
|
||||
export const forgotPasswordEmail = defineEmail({
|
||||
id: 'forgotPassword',
|
||||
subject: 'Password reset notification - ClassroomIO',
|
||||
schema: z.object({
|
||||
email: z.email(),
|
||||
name: z.string().min(1),
|
||||
link: z.url()
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `Hello ${fields.name},
|
||||
<p>You are receiving this email because you have requested a password reset for your ClassroomIO account.</p>
|
||||
<p>Please click the button below to reset your password:</p>
|
||||
|
||||
<div>
|
||||
<a class="button" href="${fields.link}">Reset my password</a>
|
||||
</div>
|
||||
|
||||
<p>PS: If you did not initiate this request, reply to this email or write to [email protected] so we can look into a possible attempt to breach your account.</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Auto-import all email definitions to register them in the registry
|
||||
*
|
||||
* When this module is imported, all emails are automatically registered
|
||||
* and available for use with the send() function.
|
||||
*/
|
||||
|
||||
// Import all email definitions (side effect: registers them)
|
||||
export * from './forgot-password';
|
||||
export * from './welcome';
|
||||
export * from './verify-email';
|
||||
export * from './on-password-reset';
|
||||
|
||||
// Import types for schema mapping (must be after exports to avoid circular dependency)
|
||||
import type { forgotPasswordEmail } from './forgot-password';
|
||||
import type { onPasswordResetEmail } from './on-password-reset';
|
||||
import type { verifyEmailEmail } from './verify-email';
|
||||
import type { welcomeEmail } from './welcome';
|
||||
|
||||
/**
|
||||
* Type mapping that maps each EmailId to its corresponding Zod schema type
|
||||
* This is used for compile-time type inference in sendEmail()
|
||||
*
|
||||
* **Important**: When adding a new email, add its schema mapping here!
|
||||
*/
|
||||
export type EmailSchemas = {
|
||||
forgotPassword: typeof forgotPasswordEmail.template.schema;
|
||||
welcome: typeof welcomeEmail.template.schema;
|
||||
verifyEmail: typeof verifyEmailEmail.template.schema;
|
||||
onPasswordReset: typeof onPasswordResetEmail.template.schema;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
|
||||
export const onPasswordResetEmail = defineEmail({
|
||||
id: 'onPasswordReset',
|
||||
subject: 'Password Reset Successful - ClassroomIO',
|
||||
schema: z.object({
|
||||
name: z.string().min(1)
|
||||
}),
|
||||
render: (fields) => {
|
||||
return getDefaultTemplate(`Heya ${fields.name},
|
||||
<p>Your password has been reset successfully.</p>
|
||||
`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
|
||||
export const verifyEmailEmail = defineEmail({
|
||||
id: 'verifyEmail',
|
||||
subject: 'Action Required: Confirm your email',
|
||||
schema: z.object({
|
||||
link: z.url(),
|
||||
newEmail: z.email().optional(),
|
||||
userName: z.string().optional()
|
||||
}),
|
||||
render: (fields) => {
|
||||
const isEmailChange = !!fields.newEmail;
|
||||
const userName = fields.userName || 'there';
|
||||
|
||||
const content = isEmailChange
|
||||
? `
|
||||
<p><strong>Hey ${userName} 👋</strong></p>
|
||||
<p>You have requested to change your email address to <strong>${fields.newEmail}</strong>.</p>
|
||||
<p>To approve this change, please click the button below:</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.link}">Approve Email Change</a>
|
||||
</div>
|
||||
<p>If you did not request this change, please ignore this email.</p>
|
||||
`
|
||||
: `
|
||||
<p><strong>Hey ${userName} 👋</strong></p>
|
||||
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email. </p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below
|
||||
</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.link}">Verify</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
|
||||
export const welcomeEmail = defineEmail({
|
||||
id: 'welcome',
|
||||
subject: 'Welcome to ClassroomIO!',
|
||||
schema: z.object({
|
||||
name: z.string().min(1)
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Dear ${fields.name},</p>
|
||||
<p>My name is Best, the founder of ClassroomIO. I saw you signed up, any questions so far?</p>
|
||||
<p>
|
||||
Would you like to get a walkthrough of the platform in a 30 minutes product demo? It's free and we won't try to sell you anything, just want to learn from your use case and show you all the cool features and flows we thought about.
|
||||
</p>
|
||||
<p>
|
||||
Interested? just book a 30-minute demo slot in our calendar!
|
||||
</p>
|
||||
<div>
|
||||
<a class="button" href="https://classroomio.com/demo">Book demo</a>
|
||||
</div>
|
||||
<p>
|
||||
PS: I reply personally to every email. We don't have an outsourced support team at the other end of the globe.. ;-)
|
||||
</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @cio/email - Unified Email Package
|
||||
*
|
||||
* Declarative schema-only email system with unified send() API
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { sendEmail } from '@cio/email';
|
||||
*
|
||||
* await sendEmail('forgotPassword', {
|
||||
* to: '[email protected]',
|
||||
* fields: {
|
||||
* name: 'John Doe',
|
||||
* email: '[email protected]',
|
||||
* link: 'https://app.classroomio.com/reset?token=abc123'
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Pre-import all emails to register them in the registry
|
||||
import './emails';
|
||||
|
||||
// Export unified API
|
||||
export { sendEmail, defineEmail } from './send';
|
||||
|
||||
// Export types for advanced usage
|
||||
export type { EmailTemplate, EmailDefinition, SendConfig, DefineEmailConfig } from './core/types';
|
||||
export type { EmailId, EmailSchemaFor } from './utils/types';
|
||||
|
||||
// Re-export low-level utilities
|
||||
export { deliverEmail } from './send';
|
||||
export { getDefaultTemplate } from './templates/default';
|
||||
|
||||
// Export email registry for introspection (optional)
|
||||
export { EmailRegistry } from './core/registry';
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { ZDeliverEmail, type TDeliverEmail } from '@cio/utils/validation/mail';
|
||||
import { env } from './config/env';
|
||||
import { sendWithZoho } from './utils/services/zeptomail';
|
||||
import { sendWithNodemailer } from './utils/services/nodemailer';
|
||||
import type { EmailResponse, EmailId, EmailSchemaFor } from './utils/types';
|
||||
import type { DefineEmailConfig, EmailDefinition, EmailTemplate, SendConfig } from './core/types';
|
||||
import { EmailRegistry } from './core/registry';
|
||||
import { ZodError } from 'zod';
|
||||
import { sendTemplateEmail } from './core/base';
|
||||
import { EMAIL_FROM, EMAIL_REPLY_TO } from './utils/constants';
|
||||
|
||||
/**
|
||||
* Define an email template with schema validation
|
||||
* The email is automatically registered in the registry for use with sendEmail()
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export const forgotPasswordEmail = defineEmail({
|
||||
* id: 'forgotPassword',
|
||||
* subject: 'Password reset notification',
|
||||
* schema: z.object({
|
||||
* name: z.string(),
|
||||
* link: z.url()
|
||||
* }),
|
||||
* render: (fields) => `Hello ${fields.name}, reset here: ${fields.link}`
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineEmail<TSchema extends z.ZodType>(config: DefineEmailConfig<TSchema>): EmailDefinition<TSchema> {
|
||||
const template: EmailTemplate<TSchema> = {
|
||||
subject: config.subject,
|
||||
schema: config.schema,
|
||||
render: config.render,
|
||||
from: config.from ?? EMAIL_FROM,
|
||||
replyTo: config.replyTo ?? EMAIL_REPLY_TO
|
||||
};
|
||||
|
||||
// Auto-register in registry
|
||||
EmailRegistry.register(config.id, template);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
template
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email template using its registered ID
|
||||
*
|
||||
* This function looks up the template by ID, validates the fields against the template's schema,
|
||||
* renders the content, and delivers the email.
|
||||
*
|
||||
* The `fields` type is automatically inferred based on the `emailId` passed, providing
|
||||
* full type safety and autocomplete.
|
||||
*
|
||||
* @param emailId - The ID of the email template (type-safe, only valid IDs accepted)
|
||||
* @param config - Send configuration with recipient and fields (fields type is inferred from emailId)
|
||||
* @returns Promise resolving to email send results
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await sendEmail('forgotPassword', {
|
||||
* to: '[email protected]',
|
||||
* fields: {
|
||||
* name: 'John Doe',
|
||||
* email: '[email protected]',
|
||||
* link: 'https://app.classroomio.com/reset?token=abc123'
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function sendEmail<TEmailId extends EmailId>(
|
||||
emailId: TEmailId,
|
||||
config: SendConfig<EmailSchemaFor<TEmailId>>
|
||||
): Promise<EmailResponse[]> {
|
||||
const template = EmailRegistry.get(emailId);
|
||||
|
||||
if (!template) {
|
||||
const availableIds = EmailRegistry.getAllIds();
|
||||
throw new Error(
|
||||
`Email template "${emailId}" not found. Available emails: ${availableIds.length > 0 ? availableIds.join(', ') : 'none registered'}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate fields against template schema
|
||||
const validatedFields = template.schema.parse(config.fields);
|
||||
|
||||
// Send using base function
|
||||
return sendTemplateEmail({
|
||||
to: config.to,
|
||||
template,
|
||||
fields: validatedFields,
|
||||
from: config.from ?? template.from,
|
||||
replyTo: config.replyTo ?? template.replyTo
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
console.log('error', error);
|
||||
const errorMessages = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
throw new Error(`Invalid fields for email "${emailId}": ${errorMessages}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver raw email data via email service (Zoho or Nodemailer)
|
||||
*
|
||||
* This is a low-level function that takes raw email data (to, subject, content, etc.)
|
||||
* and delivers it through the configured email service. Use sendEmail() for
|
||||
* sending template-based emails instead.
|
||||
*
|
||||
* @param args - Array of email data objects (to, subject, content, from, replyTo)
|
||||
* @returns Promise resolving to email delivery results
|
||||
*/
|
||||
export const deliverEmail = async (args: TDeliverEmail): Promise<EmailResponse[]> => {
|
||||
const emailItems = ZDeliverEmail.parse(args);
|
||||
|
||||
const results = await Promise.all(
|
||||
emailItems.map(async (emailItem) => {
|
||||
try {
|
||||
const res = env.ZOHO_TOKEN ? await sendWithZoho(emailItem) : await sendWithNodemailer(emailItem);
|
||||
|
||||
console.log('Email status', res);
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
export const getDefaultTemplate = (content: string): string =>
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||
<base target="_blank" />
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #F6F9FF;
|
||||
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 26px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px dashed #94a3b8;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: 5px 10px 0px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.socialicons {
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
width: initial;
|
||||
margin: 0 auto;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.brandcolor {
|
||||
color: #00c4b8;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
color: #475569;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tooltip a {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top: 12px;
|
||||
background: #0233BD;
|
||||
border-radius: 8px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500;
|
||||
padding: 10px 30px;
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #0233BD;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 5px 30px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid rgb(221, 208, 208);
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gutter img {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.wrap {
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="
|
||||
background-color: #F6F9FF;
|
||||
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
padding: 20px 0;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
">
|
||||
<div class="gutter">
|
||||
<a href="https://classroomio.com" target="_blank">
|
||||
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO Logo" /></a>
|
||||
</div>
|
||||
<div class="wrap" style="
|
||||
background-color: #fff;
|
||||
padding: 40px 30px;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
">
|
||||
${content}
|
||||
</div>
|
||||
|
||||
<div class="footer" style="
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
padding: 30px;
|
||||
background-color: #0542CC;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;">
|
||||
<p>Find us on</p>
|
||||
<table class="socialicons">
|
||||
<tr>
|
||||
<td>
|
||||
<a target="_blank" href="https://twitter.com/classroomio"><img title="X"
|
||||
src="https://brand.cdn.clsrio.com/socials/twitter.png" alt="X" width="19" /></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://www.youtube.com/@ClassroomIO"><img title="Youtube"
|
||||
src="https://brand.cdn.clsrio.com/socials/youtube.png" alt="Youtube" width="24" /></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://www.facebook.com/classroomiohq"><img title="Facebook"
|
||||
src="https://brand.cdn.clsrio.com/socials/facebook.png" alt="Facebook" width="24" /></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="padding-top: 8px; line-height: initial">
|
||||
ClassroomIO ${new Date().getFullYear()}. All rights reserved.<br />
|
||||
<a style="text-decoration: none" href="https://app.enzuzo.com/policies/tos/958fc978-5477-11ee-a03b-7b111830c594"
|
||||
target="_blank">Terms</a>
|
||||
|
|
||||
<a style="text-decoration: none"
|
||||
href="https://app.enzuzo.com/policies/privacy/958fc978-5477-11ee-a03b-7b111830c594" target="_blank">Privacy
|
||||
Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './default';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const EMAIL_IDS = ['forgotPassword', 'welcome', 'verifyEmail', 'onPasswordReset'] as const;
|
||||
|
||||
export const EMAIL_FROM = '"Best from ClassroomIO" <[email protected]>';
|
||||
export const EMAIL_REPLY_TO = '"Best from ClassroomIO" <[email protected]>';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FromData } from '../types';
|
||||
|
||||
// format: "ClassroomIO Developers (via ClassroomIO.com)" <[email protected]>
|
||||
export function extractNameAndEmail(str: string): FromData | undefined {
|
||||
// Use regular expressions to match the name and email
|
||||
const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/;
|
||||
const match = str.match(regex);
|
||||
|
||||
if (match) {
|
||||
// Extract the name and email from the match groups
|
||||
const name = match[1];
|
||||
const email = match[2] + '@' + match[3];
|
||||
return { name, email };
|
||||
} else {
|
||||
// Return undefined if the format doesn't match
|
||||
return { name: str, email: str };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EmailResponse } from '../types';
|
||||
import type { TEmailData } from '@cio/utils/validation/mail';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { env } from '../../config/env';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
let transporter: Transporter | undefined;
|
||||
|
||||
const setupTransporter = async () => {
|
||||
if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) {
|
||||
console.error('SMTP configuration missing');
|
||||
}
|
||||
|
||||
try {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT || '465', 10),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
console.error('Transporter error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setupTransporter();
|
||||
|
||||
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content, replyTo } = emailData;
|
||||
|
||||
if (!transporter) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Email transporter not initialized'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transporter.sendMail({
|
||||
from: from || env.SMTP_SENDER,
|
||||
to,
|
||||
subject,
|
||||
replyTo,
|
||||
html: content
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
details: result
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { EmailResponse } from '../types';
|
||||
import { SendMailClient } from 'zeptomail';
|
||||
import type { TEmailData } from '@cio/utils/validation/mail';
|
||||
import { env } from '../../config/env';
|
||||
import { extractNameAndEmail } from '../functions/email-helpers';
|
||||
|
||||
export const zohoClient = new SendMailClient({
|
||||
url: 'api.zeptomail.eu/',
|
||||
token: env.ZOHO_TOKEN!
|
||||
});
|
||||
|
||||
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content, replyTo } = emailData;
|
||||
|
||||
const fromData = extractNameAndEmail(from || '');
|
||||
|
||||
if (!fromData) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid from data'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await zohoClient.sendMail({
|
||||
from: {
|
||||
address: fromData.email,
|
||||
name: fromData.name
|
||||
},
|
||||
to: [
|
||||
{
|
||||
email_address: {
|
||||
address: to
|
||||
}
|
||||
}
|
||||
],
|
||||
subject,
|
||||
htmlbody: content,
|
||||
reply_to: getReplyTo(replyTo)
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
details: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error sending email with Zoho:', error);
|
||||
console.log(error.error.details?.[0]);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getReplyTo(replyTo?: string): { address: string; name: string }[] | undefined {
|
||||
if (!replyTo) return undefined;
|
||||
|
||||
const fromData = extractNameAndEmail(replyTo);
|
||||
if (!fromData) return undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
address: fromData.email,
|
||||
name: fromData.name
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { EMAIL_IDS } from './constants';
|
||||
import type { EmailSchemas } from '../emails';
|
||||
|
||||
export interface EmailResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface FromData {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
export type EmailId = (typeof EMAIL_IDS)[number];
|
||||
|
||||
/**
|
||||
* Conditional type that looks up the schema type for a given EmailId
|
||||
*
|
||||
* @example
|
||||
* type ForgotPasswordSchema = EmailSchemaFor<'forgotPassword'>;
|
||||
*/
|
||||
export type EmailSchemaFor<TEmailId extends EmailId> = TEmailId extends keyof EmailSchemas
|
||||
? EmailSchemas[TEmailId]
|
||||
: never;
|
||||
Reference in New Issue
Block a user