File

apps/recallassess/recallassess-api/src/api/shared/email/services/email-test.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No

Methods

Private generateTestData
generateTestData(templateKey: string)

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.

Parameters :
Name Type Optional
templateKey string No
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 is_active on. Filtering by is_active: true here previously broke that workflow with a misleading 404 even though the row existed.

Parameters :
Name Type Optional
dto PreviewEmailDto No
Returns : unknown
Private replaceVariables
replaceVariables(content: string, variables: Record)

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.

Parameters :
Name Type Optional
content string No
variables Record<string | string> No
Returns : string
Private Async resolveBannerForTestRecipient
resolveBannerForTestRecipient(recipientEmail: string)

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)
Parameters :
Name Type Optional
recipientEmail string No
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:

  • 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.

Parameters :
Name Type Optional
dto TestEmailDto No
Returns : unknown

Properties

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);
  }
}

results matching ""

    No results matching ""