apps/recallassess/recallassess-api/src/api/shared/email/services/email-test.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
|
|||||||||
|
Parameters :
|
| Private generateTestData | ||||||
generateTestData(templateKey: string)
|
||||||
|
Generate test data for email templates. NOTE:
Parameters :
Returns :
Record<string, string>
|
| Async listTemplates |
listTemplates()
|
|
List all available email templates
Returns :
unknown
|
| Async previewTemplate | ||||||
previewTemplate(dto: PreviewEmailDto)
|
||||||
|
Generate preview of email template with test data. Preview is admin-only and does NOT send anything, so we deliberately
accept inactive templates here — admins need to be able to QA drafts
and disabled templates before flipping
Parameters :
Returns :
unknown
|
| Private replaceVariables | |||||||||
replaceVariables(content: string, variables: Record
|
|||||||||
|
Replace variables in template content for preview/testAllTemplates. Delegates to the shared
Parameters :
Returns :
string
|
| Private Async resolveBannerForTestRecipient | ||||||
resolveBannerForTestRecipient(recipientEmail: string)
|
||||||
|
Decide what value Cases:
Parameters :
Returns :
Promise<string>
|
| Async testAllTemplates |
testAllTemplates()
|
|
Test all templates
Returns :
unknown
|
| Async testEmail | ||||||
testEmail(dto: TestEmailDto)
|
||||||
|
Test sending an email (development only). Subscription-banner logic:
The banner HTML itself is built by
Parameters :
Returns :
unknown
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(EmailTestService.name)
|
import { renderEmailTemplate } from "@api/shared/email/render-email-template.util";
import { resolveFrontendUrl } from "@api/shared/email/resolve-frontend-url.util";
import {
isCompanySubscriptionActiveForEmailBanner,
loadSubscriptionExpiredBannerHtml,
} from "@api/shared/email/subscription-expired-banner-html.util";
import { BNestEmailSenderService, optionalEnv, optionalEnvBool, requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PreviewEmailDto, TestEmailDto } from "../dto/preview-email.dto";
@Injectable()
export class EmailTestService {
private readonly logger = new Logger(EmailTestService.name);
constructor(
private prisma: BNestPrismaService,
private emailSender: BNestEmailSenderService,
) {}
/**
* List all available email templates
*/
async listTemplates() {
const templates = await this.prisma.client.emailTemplate.findMany({
where: {
is_active: true,
},
orderBy: {
template_key: "asc",
},
select: {
id: true,
template_key: true,
subject: true,
template_type: true,
description: true,
is_active: true,
created_at: true,
updated_at: true,
},
});
return {
success: true,
count: templates.length,
templates,
};
}
/**
* Generate preview of email template with test data.
*
* Preview is admin-only and does NOT send anything, so we deliberately
* accept inactive templates here — admins need to be able to QA drafts
* and disabled templates before flipping `is_active` on. Filtering by
* `is_active: true` here previously broke that workflow with a
* misleading 404 even though the row existed.
*/
async previewTemplate(dto: PreviewEmailDto) {
const template = await this.prisma.client.emailTemplate.findFirst({
where: { template_key: dto.templateKey },
});
if (!template) {
throw new NotFoundException(`Email template not found: ${dto.templateKey}`);
}
// Merge defaults with caller variables so partial payloads still get subscription banner, etc.
const testVariables = { ...this.generateTestData(dto.templateKey), ...(dto.variables ?? {}) };
// Replace variables (including any conditional {{#if}}{{else}}{{/if}} blocks)
const processedSubject = this.replaceVariables(template.subject, testVariables);
const processedContent = this.replaceVariables(template.message_body, testVariables);
return {
success: true,
template: {
id: template.id,
templateKey: template.template_key,
subject: processedSubject,
content: processedContent,
originalSubject: template.subject,
variables: testVariables,
},
};
}
/**
* Test sending an email (development only).
*
* Subscription-banner logic:
* - Look up the recipient email in the participant table.
* - If we find a participant AND their company's most recent subscription is
* NOT ACTIVE (CANCELLED / SUSPENDED / EXPIRED / null), inject the red
* "YOUR SUBSCRIPTION HAS EXPIRED" banner via {{subscription.expired_banner_html}}.
* - If the recipient is an active member, the banner stays empty (matches what
* the participant would see in a real production send).
* - If we can't find a matching participant (test address like
* `mohamedsarjun.pilot+viptest1@gmail.com`), default to NO banner. The admin
* can still force the banner explicitly by passing `subscription.expired_banner_html`
* in `dto.variables`, or by setting env var TEST_EMAIL_SHOW_EXPIRED_BANNER=true.
*
* The banner HTML itself is built by `buildSubscriptionExpiredBannerHtml()` which
* uses a table-based layout (bgcolor + role=presentation) so it renders
* consistently in Outlook (Word engine), Apple Mail (WebKit), Gmail web/iOS/Android,
* Yahoo, Outlook.com, etc.
*/
async testEmail(dto: TestEmailDto) {
const template = await this.prisma.client.emailTemplate.findFirst({
where: { template_key: dto.templateKey },
});
if (!template) {
throw new NotFoundException(`Email template not found: ${dto.templateKey}`);
}
if (!template.is_active) {
// Row exists but is disabled. The downstream BNestEmailSenderService
// also filters by is_active and would surface the same generic
// "not found" error, leaving admins confused. Pre-check here and
// give a clear, actionable message instead.
throw new BadRequestException(
`Email template "${dto.templateKey}" is currently inactive. Activate it on the Main Info tab and try again.`,
);
}
// Merge defaults with caller variables. Caller can override anything.
const testVariables = { ...this.generateTestData(dto.templateKey), ...(dto.variables ?? {}) };
const recipientEmail = dto.recipientEmail || testVariables["user.email"] || "test@example.com";
// Add unsubscribe URLs for testing
const frontendUrl = requireEnv("FRONTEND_URL");
const testUnsubscribeUrl = `${frontendUrl}/unsubscribe?token=test-token-${Date.now()}`;
const unsub = optionalEnv("UNSUBSCRIBE_EMAIL", "");
const fromSes = optionalEnv("AWS_SES_FROM_EMAIL", "");
const unsubscribeEmail = unsub !== "" ? unsub : fromSes !== "" ? fromSes : "unsubscribe@recallsolutions.ai";
// Add unsubscribe URLs to test variables
testVariables["system.unsubscribeUrl"] = testUnsubscribeUrl;
testVariables["system.managePreferencesUrl"] = `${frontendUrl}/portal/settings/notifications`;
// ------------------------------------------------------------------------
// Smart subscription-expired banner: inject ONLY if the recipient's
// company has a non-ACTIVE subscription. Active subscribers see no banner.
//
// Override semantics:
// - Missing key OR empty string → run smart subscription lookup (the
// default — what the admin "Test email" button hits, since the admin
// PWA frontend sends "" as the dummy value for this field).
// - Non-empty string in dto.variables → admin explicitly forcing a
// banner HTML for visual QA; we use it as-is.
//
// Earlier the check was `=== undefined`, which meant the admin-PWA
// sending `""` skipped the smart logic and produced a banner-less test
// email even for non-active subscribers. Fixed: treat empty as
// "use smart default", so the banner correctly reflects subscription
// state for the recipient.
// ------------------------------------------------------------------------
const callerProvidedBanner = dto.variables?.["subscription.expired_banner_html"];
if (callerProvidedBanner === undefined || callerProvidedBanner === "") {
const resolvedBanner = await this.resolveBannerForTestRecipient(recipientEmail);
testVariables["subscription.expired_banner_html"] = resolvedBanner;
}
// Otherwise: keep whatever the admin sent (a custom non-empty HTML string).
try {
// Send email using the email sender service
await this.emailSender.sendTemplatedEmail({
to: recipientEmail,
templateKey: dto.templateKey,
variables: testVariables,
metadata: {
test: true,
testDate: new Date().toISOString(),
},
unsubscribeUrl: testUnsubscribeUrl,
unsubscribeEmail,
});
const useSes = optionalEnvBool("USE_AWS_SES", false);
const message = useSes
? `Test email sent to ${recipientEmail}. Check Email Log and inbox.`
: `Test email logged (no real send). Set USE_AWS_SES=true and configure AWS SES to receive real emails. Recipient: ${recipientEmail}.`;
return {
success: true,
message,
templateKey: dto.templateKey,
recipientEmail,
};
} catch (error) {
this.logger.error(`Failed to send test email:`, error);
throw error;
}
}
/**
* Decide what value `subscription.expired_banner_html` should resolve to for
* a Test Email send. Look up the recipient in the participant table; if their
* company's latest subscription is NOT ACTIVE, return the rendered banner
* HTML. Otherwise return empty string.
*
* Cases:
* - Recipient matches a real participant + company subscription is ACTIVE → "" (no banner)
* - Recipient matches a real participant + subscription is non-ACTIVE → banner shown
* - Recipient doesn't match any participant (e.g. `name+tag@gmail.com` test address)
* → banner shown by default. Admin testing from a generic address typically wants
* to verify the banner renders correctly. To override and force NO banner for a
* no-match test recipient, set env TEST_EMAIL_SUPPRESS_BANNER_FOR_UNKNOWN=true.
* - Any DB lookup error → "" (fail safe — don't show banner if we can't tell)
*/
private async resolveBannerForTestRecipient(recipientEmail: string): Promise<string> {
try {
const participant = await this.prisma.client.participant.findFirst({
where: { email: recipientEmail },
select: { company_id: true },
});
if (participant?.company_id != null) {
const isActive = await isCompanySubscriptionActiveForEmailBanner(
this.prisma.client,
participant.company_id,
);
if (isActive) {
this.logger.debug(
`Test email to ${recipientEmail} — company ${participant.company_id} subscription is ACTIVE → no banner`,
);
return "";
}
// Non-ACTIVE → render the banner with the configured contact email.
const setting = await (this.prisma.client as any).systemSetting?.findFirst?.({
where: { key: "mail.contact_email" },
select: { value: true },
});
const contactEmail =
(typeof setting?.value === "string" && setting.value.trim()) ||
"enquiries@recallsolutions.ai";
this.logger.debug(
`Test email to ${recipientEmail} — company ${participant.company_id} subscription is NOT ACTIVE → injecting banner`,
);
return await loadSubscriptionExpiredBannerHtml(this.prisma.client, contactEmail);
}
// No participant match — admin is testing to a generic address (e.g.
// gmail+tag, throwaway test inbox, internal QA address). Default to
// SHOWING the banner so admins can visually verify the banner renders
// correctly across mail clients. To force NO banner in this case, set
// env TEST_EMAIL_SUPPRESS_BANNER_FOR_UNKNOWN=true.
const suppressBannerForUnknown = optionalEnvBool("TEST_EMAIL_SUPPRESS_BANNER_FOR_UNKNOWN", false);
if (suppressBannerForUnknown) {
this.logger.debug(
`Test email to ${recipientEmail} — no participant match, TEST_EMAIL_SUPPRESS_BANNER_FOR_UNKNOWN=true → no banner`,
);
return "";
}
const unsub = optionalEnv("UNSUBSCRIBE_EMAIL", "");
const fromSes = optionalEnv("AWS_SES_FROM_EMAIL", "");
const bannerContactEmail =
unsub !== "" ? unsub : fromSes !== "" ? fromSes : "enquiries@recallsolutions.ai";
this.logger.debug(
`Test email to ${recipientEmail} — no participant match → injecting banner (default for unknown recipients)`,
);
return await loadSubscriptionExpiredBannerHtml(this.prisma.client, bannerContactEmail);
} catch (err) {
this.logger.warn(
`Failed to resolve subscription banner for test recipient ${recipientEmail}: ${
err instanceof Error ? err.message : String(err)
} — sending without banner.`,
);
return "";
}
}
/**
* Test all templates
*/
async testAllTemplates() {
const templates = await this.prisma.client.emailTemplate.findMany({
where: {
is_active: true,
},
orderBy: {
template_key: "asc",
},
});
const results = [];
for (const template of templates) {
try {
const testVariables = this.generateTestData(template.template_key);
const processedSubject = this.replaceVariables(template.subject, testVariables);
const processedContent = this.replaceVariables(template.message_body, testVariables);
results.push({
templateKey: template.template_key,
success: true,
subject: processedSubject,
contentLength: processedContent.length,
});
} catch (error) {
results.push({
templateKey: template.template_key,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
return {
success: true,
summary: {
total: results.length,
successful,
failed,
},
results,
};
}
/**
* Generate test data for email templates.
*
* NOTE: `subscription.expired_banner_html` defaults to empty here. The
* caller-facing `testEmail()` method overrides this AFTER mergeing dummy
* data, based on the recipient's actual subscription status (see
* `resolveBannerForTestRecipient`). The empty default is what an
* active-subscription recipient sees in production.
*/
private generateTestData(templateKey: string): Record<string, string> {
const currentYear = new Date().getFullYear().toString();
// Use the deployment's FRONTEND_URL so preview/test URLs match the environment
// (UAT → UAT URLs, prod → prod URLs, local → local). Throws in prod-like
// environments if FRONTEND_URL is missing; see resolveFrontendUrl().
const frontendUrl = resolveFrontendUrl();
const baseData: Record<string, string> = {
"user.name": "John Doe",
"user.email": "john.doe@example.com",
"user.first_name": "John",
"user.last_name": "Doe",
"company.name": "Acme Corporation",
"company.address": "123 Business St, City, State 12345",
"course.name": "Leadership Excellence Program",
"course.id": "1",
"system.loginUrl": `${frontendUrl}/sign-in`,
"system.myCourseUrl": `${frontendUrl}/portal/my-courses/1`,
"system.knowledgeReviewUrl": `${frontendUrl}/portal/my-courses/1/knowledge-review`,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.assessmentUrl": `${frontendUrl}/portal/assessment/post-bat`,
"system.managePreferencesUrl": `${frontendUrl}/portal/settings/notifications`,
"system.unsubscribeUrl": `${frontendUrl}/unsubscribe?token=sample-token-123`,
"system.resubscribeUrl": `${frontendUrl}/resubscribe?token=sample-token-123`,
"system.passwordSetupUrl": `${frontendUrl}/setup-password?token=sample-token-123`,
"system.portalBillingUrl": `${frontendUrl}/portal/settings/subscription?tab=subscription`,
"system.dashboardUrl": `${frontendUrl}/portal`,
"verification.url": `${frontendUrl}/verify-email?token=sample-token-123`,
"reset.url": `${frontendUrl}/reset-password?token=sample-reset-token-456`,
"invitation.url": `${frontendUrl}/invitation?token=sample-token-123`,
// Dynamic mail fields (so {{mail.company_name}} etc. render in preview)
"mail.company_name": "Recall",
"mail.domain_name": "recallsolutions.ai",
"mail.domain_url": "https://recallsolutions.ai",
"mail.footer_text": "Development Through Action and Reflection",
"mail.contact_email": "enquiries@recallsolutions.ai",
"mail.current_year": currentYear,
// Empty by default. testEmail() overrides per-recipient via
// resolveBannerForTestRecipient(). previewTemplate() uses this default
// (which results in no banner in the preview, matching active state).
"subscription.expired_banner_html": "",
};
const templateSpecificData: Record<string, Record<string, string>> = {
"account.welcome.email": {
...baseData,
"verification.url": `${frontendUrl}/verify-email?token=test-verification-token`,
},
"account.password.reset": {
...baseData,
"reset.url": `${frontendUrl}/reset-password?token=test-reset-token`,
},
"course.pre.bat.completion": {
...baseData,
"assessment.type": "PRE BAT",
"assessment.score": "85%",
},
"course.completion": {
...baseData,
"assessment.type": "POST BAT",
"assessment.score": "92%",
"assessment.improvement": "+7%",
},
"course.elearning.completion": {
...baseData,
"course.progress": "100%",
"course.completed_modules": "5",
"course.total_modules": "5",
"hundred_dj.email1_date": "2025-01-15",
"hundred_dj.email2_date": "2025-01-20",
"hundred_dj.email3_date": "2025-01-27",
"hundred_dj.email4_date": "2025-02-12",
},
"course.hundred.day.journey.email1": {
...baseData,
"journey.day": "2",
"journey.next_email": "7 days",
},
"course.hundred.day.journey.email2": {
...baseData,
"journey.day": "7",
"journey.next_email": "14 days",
},
"course.hundred.day.journey.email3": {
...baseData,
"journey.day": "14",
"journey.next_email": "30 days",
},
"course.hundred.day.journey.email4": {
...baseData,
"journey.day": "30",
"journey.next_email": "Knowledge Review",
},
"hundred.day.journey.email1": {
...baseData,
"journey.day": "2",
"journey.next_email": "7 days",
},
"hundred.day.journey.email2": {
...baseData,
"journey.day": "7",
"journey.next_email": "14 days",
},
"hundred.day.journey.email3": {
...baseData,
"journey.day": "14",
"journey.next_email": "30 days",
},
"hundred.day.journey.email4": {
...baseData,
"journey.day": "30",
"journey.next_email": "Knowledge Review",
},
};
return templateSpecificData[templateKey] || baseData;
}
/**
* Replace variables in template content for preview/testAllTemplates.
*
* Delegates to the shared `renderEmailTemplate` util which correctly handles
* {{#if}}{{else}}{{/if}} and {{#unless}}{{/unless}} blocks alongside plain
* {{key}} placeholders. The previous local implementation here stripped
* {{#if}}/{{/if}} independently, leaving the literal `{{else}}` token AND
* both branches visible in test/preview output.
*/
private replaceVariables(content: string, variables: Record<string, string>): string {
return renderEmailTemplate(content, variables);
}
}