Clients/EmailClient.ts

import { IAuth } from "../auth/IAuth";
import { sendlix } from "../proto/email";
import { EmailData } from "../proto/EmailData";
import { readFile } from "fs/promises";
import { google } from "../proto/google/protobuf/timestamp";
import { Client } from "./Client";

const {
  SendMailRequest,
  MailContent,
  AdditionalInfos,
  AttachmentData,
  EmlMailRequest,
  EmailClient: gRPCEmailClient,
  GroupMailData,
  Images,
  MimeType,
} = sendlix.api.v1;

/**
 * Image attachment configuration
 *
 * !Sendlix Plus feature
 * @typedef {Object} Images
 * @property {string} placeholder - Placeholder string in the email content to be replaced by the image
 * @property {ArrayBuffer} data - Binary data of the image
 * @property {"PNG" | "JPG" | "GIF"} type - Type of the image
 */
type Images = {
  placeholder: string;
  data: ArrayBuffer | Uint8Array;
  type: keyof typeof MimeType;
};

/**
 * Response type for the email sending operation
 * @typedef {Object} SendEmailResponse
 * @property {string[]} messageList - List of the email messages ids sent
 * @property {number} emailsLeft - Number of email credits left in the account
 */
type Response = {
  messageList: string[];
  emailsLeft: number;
};

/**
 * Configuration object for sending an email
 * @typedef {Object} mailOption
 * @property {EmailAddress} from - Sender email address and optional name
 * @property {EmailAddress[]} to - List of recipient email addresses
 * @property {EmailAddress[]} [cc] - Optional list of CC recipients
 * @property {EmailAddress[]} [bcc] - Optional list of BCC recipients
 * @property {string} subject - Email subject line
 * @property {EmailAddress} [replyTo] - Optional reply-to address
 * @property {string} [html] - Optional HTML content of the email
 * @property {string} [text] - Optional plain text content of the email
 * @property {boolean} [tracking] - Optional flag for tracking links in the email
 * @property {Images[]} [images] - Optional list of images to embed in the email
 *
 */
type mailOption = {
  from: EmailAddress;
  to: EmailAddress[];
  cc?: EmailAddress[];
  bcc?: EmailAddress[];
  subject: string;
  replyTo?: EmailAddress;
  html?: string;
  text?: string;
  tracking?: boolean;
  images?: Images[];
};

/**
 * Email content type
 * @typedef {Object} EmailContent
 * @property {string} from - Sender email address
 * @property {string} subject - Subject of the email
 * @property {string} category - Optional category for the email
 * @property {string} [html] - Optional HTML content of the email
 * @property {string} [text] - Optional plain text content of the email
 * @property {boolean} [tracking] - Optional flag for tracking links in the email
 **/

type GroupMailData = {
  from: EmailAddress;
  groupId: string;
  subject: string;
  category?: string;
  html?: string;
  text?: string;
  tracking?: boolean;
  images?: Images[];
};

/**
 * Email address with optional display name
 * @typedef {Object} EmailAddress
 * @property {string} email - The email address
 * @property {string} [name] - Optional display name
 */
type EmailAddress =
  | {
      email: string;
      name?: string;
    }
  | string;

/**
 * Additional options for sending emails
 * @typedef {Object} AdditionalEmailOptions
 * @property {Attachment[]} [attachments] - Optional list of email attachments
 * @property {string} [category] - Optional category for the email
 * @property {Date} [send_at] - Optional scheduled send time
 */
type AdditionalEmailOptions = {
  attachments?: Attachment[];
  category?: string;
  send_at?: Date;
};

/**
 * Email attachment configuration
 * @typedef {Object} Attachment
 * @property {string} contentURL - URL or path to the attachment content
 * @property {string} filename - Name of the file shown to recipients
 * @property {string} [contentType] - Optional MIME type
 * @property {string} [contentDisposition] - Optional content disposition (inline/attachment)
 */
type Attachment = {
  contentURL: string;
  filename: string;
  contentType?: string;
};

/**
 * Client for interacting with Sendlix Email Service API
 * Provides methods for sending various types of emails
 */
export class EmailClient extends Client<typeof gRPCEmailClient> {
  /**
   * Creates a new EmailClient instance
   * @param {IAuth|string} auth - Authentication credentials or API key
   */
  constructor(auth: IAuth | string) {
    super(auth, gRPCEmailClient);
  }

  /**
   * Sends an email with the specified configuration
   * @param {mailOption} mailOption - Email configuration
   * @param {AdditionalEmailOptions} [additionalOptions] - Optional additional settings
   * @returns {Promise<Response>} Promise resolving to the email send response
   * @example
   * const client = new EmailClient('your-api-key');
   * const response = await client.sendEmail({
   *    from: { email: 'sender@example.com', name: 'Sender Name' },
   *    to: [{ email: 'recipient@example.com', name: 'Recipient Name' }],
   *    subject: 'Test Email',
   *    content: {
   *        value: '<h1>Hello World!</h1><p>This is a test email.</p>',
   *        type: 'html'
   *    },
   * });
   *
   */
  public sendEmail(
    mailOption: mailOption,
    additionalOptions?: AdditionalEmailOptions
  ): Promise<Response> {
    if (
      !mailOption.from ||
      !mailOption.from ||
      !mailOption.subject ||
      !mailOption.to ||
      !(mailOption.text || mailOption.html)
    ) {
      throw new Error(
        "Missing required fields: from, to, subject, and content are required."
      );
    }

    const mailData = new SendMailRequest({
      from: createEmailAddress(mailOption.from),
      to: mailOption.to.map(createEmailAddress),
      subject: mailOption.subject,
      TextContent: new MailContent({
        html: mailOption.html,
        text: mailOption.text,
        tracking: mailOption.tracking || false,
        Images: createImages(mailOption.images),
      }),
    });
    if (mailOption.cc) {
      mailData.cc = mailOption.cc.map(createEmailAddress);
    }
    if (mailOption.bcc) {
      mailData.bcc = mailOption.bcc.map(createEmailAddress);
    }
    if (mailOption.replyTo) {
      mailData.reply_to = createEmailAddress(mailOption.replyTo);
    }

    if (additionalOptions) {
      const additionalInfos = createAdditionalEmailOptions(additionalOptions);
      mailData.additionalInfos = additionalInfos;
    }

    return new Promise<Response>((resolve, reject) => {
      this.client.SendEmail(mailData, (error, response) => {
        if (error) {
          reject(error);
        } else {
          resolve({
            messageList: response?.message || [],
            emailsLeft: response?.emailsLeft!,
          });
        }
      });
    });
  }

  /**
   * Sends a pre-formatted email in raw EML format
   *
   * For more information on how to we handle raw emails, please refer to the documentation:
   *
   * https://docs.sendlix.com/emlemail
   *
   * @param {string|Buffer|Uint8Array} eml - Raw email in EML format, can be a filepath string
   * @param {AdditionalEmailOptions} [additionalOptions] - Optional additional settings
   * @returns {Promise<Response>} Promise resolving to the email send response
   */
  public async sendEmlEmail(
    eml: string | Buffer | Uint8Array,
    additionalOptions?: AdditionalEmailOptions
  ): Promise<Response> {
    if (typeof eml === "string") {
      eml = await readFile(eml, "utf8");
    }

    if (eml instanceof Buffer) {
      eml = new Uint8Array(eml.buffer, eml.byteOffset, eml.byteLength);
    }

    const rawMail = new EmlMailRequest();
    rawMail.mail = eml as Uint8Array;

    if (additionalOptions) {
      const additionalInfos = createAdditionalEmailOptions(additionalOptions);
      rawMail.additionalInfos = additionalInfos;
    }

    return new Promise<Response>((resolve, reject) => {
      this.client.SendEmlEmail(rawMail, (error, response) => {
        if (error) {
          reject(error);
        } else {
          resolve({
            messageList: response?.message || [],
            emailsLeft: response?.emailsLeft!,
          });
        }
      });
    });
  }

  /**
   * Sends an email to a group of recipients identified by a group ID
   * @param {GroupMailData} groupData - Group email configuration
   * @returns {Promise<number>} Promise resolving to the number of emails left in the account
   */
  public async sendGroupEmail(groupData: GroupMailData): Promise<number> {
    const mailData = new GroupMailData();
    mailData.from = createEmailAddress(groupData.from);
    mailData.groupId = groupData.groupId;
    mailData.subject = groupData.subject;

    mailData.TextContent = new MailContent({
      html: groupData.html,
      text: groupData.text,
      tracking: groupData.tracking || false,
      Images: createImages(groupData.images),
    });

    if (groupData.category) {
      mailData.category = groupData.category;
    }

    return new Promise<number>((resolve, reject) => {
      this.client.SendGroupEmail(mailData, (error, response) => {
        if (error) {
          reject(error);
        } else {
          resolve(response?.emailsLeft!);
        }
      });
    });
  }
}

function createImages(images?: Images[]): InstanceType<typeof Images>[] {
  if (!images) return [];
  return images.map((img) => {
    const image = new Images();
    image.placeholder = img.placeholder;
    image.Image = new Uint8Array(img.data);
    image.type = MimeType[img.type];
    return image;
  });
}

/**
 * Creates an EmailData object from an EmailAddress
 * @param {EmailAddress} email - The email address object to convert
 * @returns {EmailData} The converted gRPC EmailData object
 * @private
 */
function createEmailAddress(email: EmailAddress): EmailData {
  const emailData = new EmailData();
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  if (typeof email === "string") {
    if (!emailRegex.test(email)) {
      throw new Error("Invalid email address format");
    }

    emailData.email = email;

    return emailData;
  }

  emailData.email = email.email;

  if (!emailRegex.test(email.email)) {
    throw new Error("Invalid email address format");
  }

  if (email.name) {
    emailData.name = email.name;
  }
  return emailData;
}

/**
 * Creates an AdditionalInfos object from AdditionalEmailOptions
 * @param {AdditionalEmailOptions} additionalOptions - The options to convert
 * @returns {AdditionalInfos} The converted gRPC AdditionalInfos object
 * @private
 */
function createAdditionalEmailOptions(
  additionalOptions: AdditionalEmailOptions
): InstanceType<typeof AdditionalInfos> {
  const additionalInfos = new AdditionalInfos();
  if (additionalOptions.attachments) {
    const attachments = additionalOptions.attachments.map((attachment) => {
      const attachmentData = new AttachmentData();
      attachmentData.contentUrl = attachment.contentURL;
      attachmentData.filename = attachment.filename;
      if (attachment.contentType) {
        attachmentData.type = attachment.contentType;
      }
      return attachmentData;
    });
    additionalInfos.attachments = attachments;
  }
  if (additionalOptions.category) {
    additionalInfos.category = additionalOptions.category;
  }
  if (additionalOptions.send_at) {
    const timestamp = new google.protobuf.Timestamp();
    timestamp.seconds = Math.floor(additionalOptions.send_at.getTime() / 1000);
    additionalInfos.send_at = timestamp;
  }
  return additionalInfos;
}