Repository: fterh/heimdall (ts)

Issue:
Alias info command
- [ ] Alias description
- [ ] Created date
- [ ] Number of inbound emails
- [ ] Number of outbound emails
- [ ] Last inbound date
- [ ] Last outbound date

Pull request:
Support alias info command
Closes #23.

Patch:
diff --git a/README.md b/README.md
index 1e1643d..6b4b558 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ Changelog can be found under Releases.
 2. **Reply anonymously:** Reply to emails from your alias without revealing your personal email address.
 3. **Attachments:** Attachments are supported on incoming and outgoing emails (subject to size limits - see below).
 4. **Email commands:** Manage your aliases through email directly - no separate app or website required.
+5. **Usage stats:** Easily check the usage stats of each alias.
 
 ### Receiving emails
 
@@ -78,6 +79,19 @@ Dev note: This reads up to a maximum of 1MB of data (due to AWS's limitations).
 
 Email `remove@yourverifieddomain.com` with the alias as the title (case-sensitive). You will receive the operation outcome (success/failure) as a reply.
 
+### Usage stats
+
+Email `info@yourverifieddomain.com` with the alias as the title (case-sensitive).
+You will receive usage information for the particular alias.
+
+Supported usage stats:
+
+- Alias creation date
+- Emails received
+- Emails sent
+- Date of last received email
+- Date of last sent email
+
 #### Update an alias
 
 Coming soon - not supported yet.
@@ -109,9 +123,13 @@ If you want to build new features or tweak existing features, you can set up a p
 
 1. Ensure that the `DEV_SUBDOMAIN` environment variable is set in `.env` (e.g. `test`).
 2. Run `yarn run deploy-dev`.
-This creates a parallel development CloudFormation stack.
+   This creates a parallel development CloudFormation stack.
 3. Add a new receipt rule in SES **before your production rule** to trigger your development S3 bucket.
-For "recipients", enter the same test subdomain as you set in step 1 (e.g. `test.yourverifieddomain.com`).
-Preferably, name your rule descriptively (e.g. `dev`).
+   For "recipients", enter the same test subdomain as you set in step 1 (e.g. `test.yourverifieddomain.com`).
+   Preferably, name your rule descriptively (e.g. `dev`).
 
 Note: You need to update your DNS records for `test.yourverifieddomain.com` as you did when verifying your domain for AWS SES.
+
+## Migration
+
+To run migration scripts, first compile using `tsc scripts/migrate_vX.ts`, then run using `node scripts/migrate_vX.js`.
diff --git a/lib/__mocks__/generateAlias.ts b/lib/__mocks__/generateAlias.ts
deleted file mode 100644
index 6bdae9b..0000000
--- a/lib/__mocks__/generateAlias.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-export default () => "randomlygeneratedalias";
diff --git a/lib/aliasExists.ts b/lib/aliasExists.ts
deleted file mode 100644
index 41e0196..0000000
--- a/lib/aliasExists.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { DynamoDB } from "aws-sdk";
-import config from "./config";
-
-export default async (
-  alias: string,
-  client: DynamoDB.DocumentClient
-): Promise<boolean> => {
-  console.log(`Checking if alias=${alias} exists`);
-
-  const params: DynamoDB.DocumentClient.GetItemInput = {
-    TableName: config.tableName,
-    Key: {
-      alias
-    }
-  };
-
-  const res = await client.get(params).promise();
-  return !(res.Item === undefined);
-};
diff --git a/lib/commandSet.ts b/lib/commandSet.ts
index 5be14fa..8e6397f 100644
--- a/lib/commandSet.ts
+++ b/lib/commandSet.ts
@@ -2,10 +2,11 @@
  * Maintains a collection of reserved command keywords.
  */
 
-export default new Set(["generate", "list", "remove"]);
+export default new Set(["generate", "info", "list", "remove"]);
 
 export enum Commands {
   Generate = "generate",
+  Info = "info",
   List = "list",
   Remove = "remove"
 }
diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts
index 51743cf..2213a4c 100644
--- a/lib/commands/generate.ts
+++ b/lib/commands/generate.ts
@@ -1,33 +1,17 @@
-import { DynamoDB } from "aws-sdk";
 import { ParsedMail } from "mailparser";
-import aliasExists from "../aliasExists";
 import { email, operationalDomain } from "../env";
 import { Commands } from "../commandSet";
-import generateAlias from "../generateAlias";
+import Alias from "../models/Alias";
 import sendEmail from "../sendEmail";
-import storeAliasDescriptionRecord from "../storeAliasDescriptionRecord";
 
 export default async (parsedMail: ParsedMail): Promise<void> => {
-  const docClient = new DynamoDB.DocumentClient(); // Avoid re-initializing
-
   const description = parsedMail.subject;
-
-  let generatedAlias: string;
-  do {
-    generatedAlias = generateAlias();
-  } while (await aliasExists(generatedAlias, docClient));
-  console.log(
-    `Generated alias=${generatedAlias} for description=${description}`
-  );
-
-  console.log("Attempting to store alias-description record");
-  storeAliasDescriptionRecord(generatedAlias, description, docClient);
-  console.log("Successfully stored alias-description record");
+  const alias = await Alias.generateAlias(description);
 
   await sendEmail({
     from: `${Commands.Generate}@${operationalDomain}`,
     to: [email],
-    subject: `Generated alias: ${generatedAlias}`,
-    text: `You have generated ${generatedAlias}@${operationalDomain} for "${description}".`
+    subject: `Generated alias: ${alias.value}`,
+    text: `You have generated ${alias.value}@${operationalDomain} for "${alias.description}".`
   });
 };
diff --git a/lib/commands/index.ts b/lib/commands/index.ts
index 83ac993..a2e1f8a 100644
--- a/lib/commands/index.ts
+++ b/lib/commands/index.ts
@@ -2,6 +2,7 @@ import { ParsedMail } from "mailparser";
 
 import { email } from "../env";
 import generate from "./generate";
+import info from "./info";
 import list from "./list";
 import remove from "./remove";
 import { Commands } from "../commandSet";
@@ -19,12 +20,18 @@ export default async (command: string, parsedMail: ParsedMail) => {
       await generate(parsedMail);
       break;
 
+    case Commands.Info:
+      console.log("Invoking info command");
+      await info(parsedMail);
+      break;
+
     case Commands.List:
       console.log("Invoking list command");
       await list();
       break;
 
     case Commands.Remove:
+      console.log("Invoking remove command");
       await remove(parsedMail);
       break;
 
diff --git a/lib/commands/info.ts b/lib/commands/info.ts
new file mode 100644
index 0000000..6b2f064
--- /dev/null
+++ b/lib/commands/info.ts
@@ -0,0 +1,49 @@
+import { ParsedMail } from "mailparser";
+import { Commands } from "../commandSet";
+import { email, operationalDomain } from "../env";
+import Alias from "../models/Alias";
+import sendEmail from "../sendEmail";
+
+const commandEmailAddress = `${Commands.Info}@${operationalDomain}`;
+
+export const prepareAliasInfoText = (alias: Alias): string => {
+  let res = "";
+
+  res += `Alias: ${alias.value}@${operationalDomain}\n`;
+  res += `Description: ${alias.description}\n`;
+  res += `Created: ${alias.creationDate ? alias.creationDate : "Unknown"}\n`;
+  res += `Emails received: ${alias.countReceived}\n`;
+  res += `Emails sent: ${alias.countSent}\n`;
+  res += `Email last received on: ${
+    alias.lastReceivedDate ? alias.lastReceivedDate : "-"
+  }\n`;
+  res += `Email last sent on: ${
+    alias.lastSentDate ? alias.lastSentDate : "-"
+  }\n\n`;
+
+  res += `Information generated on ${new Date()}`;
+
+  return res;
+};
+
+export default async (parsedMail: ParsedMail): Promise<void> => {
+  const aliasValue = parsedMail.subject;
+  const alias = await Alias.getAlias(aliasValue);
+
+  if (alias === undefined) {
+    await sendEmail({
+      from: commandEmailAddress,
+      to: email,
+      subject: `Info: ${aliasValue}@${operationalDomain} does not exist`,
+      text: "-"
+    });
+    return;
+  }
+
+  await sendEmail({
+    from: commandEmailAddress,
+    to: email,
+    subject: `Info: ${aliasValue}@${operationalDomain}`,
+    text: prepareAliasInfoText(alias)
+  });
+};
diff --git a/lib/commands/list.ts b/lib/commands/list.ts
index 9d8893e..8a76f2a 100644
--- a/lib/commands/list.ts
+++ b/lib/commands/list.ts
@@ -1,65 +1,35 @@
-import { DynamoDB } from "aws-sdk";
-import config from "../config";
 import { email, operationalDomain } from "../env";
 import { Commands } from "../commandSet";
+import Alias from "../models/Alias";
 import sendEmail from "../sendEmail";
 
 export default async (): Promise<void> => {
-  const docClient = new DynamoDB.DocumentClient();
-  const docParams: DynamoDB.DocumentClient.ScanInput = {
-    TableName: config.tableName
-  };
+  const getAliasesResults = await Alias.getAllAliases();
 
-  const records: DynamoDB.DocumentClient.ScanOutput = await docClient
-    .scan(docParams)
-    .promise();
-
-  console.log("Successfully scanned database for records");
-
-  if (records.Items && records.Items.length === 0) {
-    return await sendEmail({
+  if (getAliasesResults.aliases.length === 0) {
+    await sendEmail({
       from: `${Commands.List}@${operationalDomain}`,
       to: [email],
-      subject: `Alias list (${new Date()})`,
+      subject: `Alias list (generated on: ${new Date()})`,
       text: "No aliases found."
     });
-  }
-
-  let mightHaveMoreRecords = false;
-  let hasMalformedRecords = false;
-
-  if (records.LastEvaluatedKey) {
-    mightHaveMoreRecords = true;
-    console.log(
-      "Scan results' LastEvaluatedKey is not undefined; there might be more records in the results set"
-    );
+    return;
   }
 
   let output = "Alias : Description\n";
-  records.Items?.forEach(item => {
-    if (item.description === undefined) {
-      hasMalformedRecords = true;
-      return console.log(
-        `Record with alias=${item.alias} is missing the "description" attribute. Skipping.`
-      );
-    }
-    output += `${item.alias} : ${item.description}\n`;
+  getAliasesResults.aliases.forEach(alias => {
+    output += `${alias.value} : ${alias.description}\n`;
   });
 
-  if (mightHaveMoreRecords) {
+  if (getAliasesResults.lastEvaluatedKey !== undefined) {
     output +=
       "There might be more records in the results set. Check the logs and database for more information.";
   }
 
-  if (hasMalformedRecords) {
-    output +=
-      "The database contains malformed records. Check the logs and database for more information.";
-  }
-
   await sendEmail({
     from: `${Commands.List}@${operationalDomain}`,
     to: [email],
-    subject: `Alias list (${new Date()})`,
+    subject: `Alias list (generated on: ${new Date()})`,
     text: output
   });
 };
diff --git a/lib/forwardInbound.ts b/lib/forwardInbound.ts
index 42a3aee..ecc2ee7 100644
--- a/lib/forwardInbound.ts
+++ b/lib/forwardInbound.ts
@@ -1,7 +1,7 @@
 import { ParsedMail } from "mailparser";
 import Mail from "nodemailer/lib/mailer";
 import { email as personalEmail, operationalDomain } from "./env";
-import getAliasDescription from "./getAliasDescription";
+import Alias from "./models/Alias";
 import repackageReceivedAttachments from "./repackageReceivedAttachments";
 import sendEmail from "./sendEmail";
 import senderAddressEncodeDecode from "./senderAddressEncodeDecode";
@@ -13,7 +13,7 @@ import senderAddressEncodeDecode from "./senderAddressEncodeDecode";
  * Prioritizes original email's "reply-to" header over "from" header.
  */
 export const generateFromHeader = (
-  alias: string,
+  aliasValue: string,
   parsedMail: ParsedMail
 ): Mail.Address => {
   let replyToEmailAddress = "";
@@ -32,27 +32,27 @@ export const generateFromHeader = (
   return {
     name: `${replyToName} <${replyToEmailAddress}>`,
     address: senderAddressEncodeDecode.encodeEmailAddress(
-      alias,
+      aliasValue,
       replyToEmailAddress
     )
   };
 };
 
 export const generateInboundMailOptions = async (
-  alias: string,
+  alias: Alias,
   parsedMail: ParsedMail
 ): Promise<Mail.Options> => {
   const mailOptions: Mail.Options = {
-    from: generateFromHeader(alias, parsedMail),
+    from: generateFromHeader(alias.value, parsedMail),
     to: parsedMail.to.value,
     cc: parsedMail.cc?.value,
-    subject: `[${await getAliasDescription(alias)}] ${parsedMail.subject}`,
+    subject: parsedMail.subject,
     html:
       parsedMail.html !== false
         ? (parsedMail.html as string) // Will never be `true`
         : parsedMail.textAsHtml,
     envelope: {
-      from: `${alias}@${operationalDomain}`, // For semantics only; this has no significance
+      from: `${alias.value}@${operationalDomain}`, // For semantics only; this has no significance
       to: personalEmail
     },
     attachments: repackageReceivedAttachments(parsedMail.attachments)
@@ -65,11 +65,19 @@ export const generateInboundMailOptions = async (
  * Forwards received email to personal email.
  * Preserves metadata while avoiding re-sending to other recipients.
  */
-export default async (alias: string, parsedMail: ParsedMail): Promise<void> => {
+export default async (
+  aliasValue: string,
+  parsedMail: ParsedMail
+): Promise<void> => {
   console.log("Attempting to forward received email to personal email");
 
+  const alias = await Alias.getAlias(aliasValue);
+  if (alias === undefined) {
+    throw new Error(`Alias=${aliasValue} not found in database!`);
+  }
   const mailOptions = await generateInboundMailOptions(alias, parsedMail);
   await sendEmail(mailOptions);
+  await alias.didReceiveEmail();
 
   console.log("Successfully forwarded email to personal email");
 };
diff --git a/lib/forwardOutbound.ts b/lib/forwardOutbound.ts
index 285cc56..ad6aea5 100644
--- a/lib/forwardOutbound.ts
+++ b/lib/forwardOutbound.ts
@@ -2,12 +2,13 @@ import addrs from "email-addresses";
 import { EmailAddress, ParsedMail } from "mailparser";
 import Mail from "nodemailer/lib/mailer";
 import { operationalDomain } from "./env";
+import Alias from "./models/Alias";
 import repackageReceivedAttachments from "./repackageReceivedAttachments";
 import sendEmail from "./sendEmail";
 import senderAddressEncodeDecode from "./senderAddressEncodeDecode";
 
 interface DecomposedUnpureAliasData {
-  pureAlias: string;
+  pureAliasValue: string;
   senderAddress: string;
 }
 
@@ -15,10 +16,12 @@ interface DecomposedUnpureAliasData {
  * Abstraction wrapper around the senderAddressEncodeDecode module
  * to handle edge cases.
  */
-export const decomposeUnpureAlias = (
-  unpureAlias: string
+export const decomposeUnpureAliasValue = (
+  unpureAliasValue: string
 ): DecomposedUnpureAliasData => {
-  const decoded = senderAddressEncodeDecode.decodeUnpureAlias(unpureAlias);
+  const decoded = senderAddressEncodeDecode.decodeUnpureAliasValue(
+    unpureAliasValue
+  );
 
   if (decoded.senderAddress === "") {
     throw new Error(
@@ -27,16 +30,16 @@ export const decomposeUnpureAlias = (
   }
 
   return {
-    pureAlias: decoded.alias,
+    pureAliasValue: decoded.aliasValue,
     senderAddress: decoded.senderAddress
   };
 };
 
 export const generateOriginalSenderEmailAddress = (
-  unpureAlias: string,
+  unpureAliasValue: string,
   originalToRecipients: Array<EmailAddress>
 ): EmailAddress => {
-  const decomposed = decomposeUnpureAlias(unpureAlias);
+  const decomposed = decomposeUnpureAliasValue(unpureAliasValue);
   let aliasIsMissing = true;
 
   const newToRecipients: Array<EmailAddress> = originalToRecipients
@@ -46,7 +49,7 @@ export const generateOriginalSenderEmailAddress = (
         addrs.parseOneAddress(recipient.address) as addrs.ParsedMailbox
     )
     // Remove all other recipients except for (unpure) alias
-    .filter(parsedRecipient => parsedRecipient.local === unpureAlias)
+    .filter(parsedRecipient => parsedRecipient.local === unpureAliasValue)
     // Generate original sender's email address object
     .map(() => {
       aliasIsMissing = false;
@@ -66,15 +69,15 @@ export const generateOriginalSenderEmailAddress = (
 };
 
 export const generateOutboundMailOptions = (
-  unpureAlias: string,
+  unpureAliasValue: string,
   parsedMail: ParsedMail
 ): Mail.Options => {
   const originalSender = generateOriginalSenderEmailAddress(
-    unpureAlias,
+    unpureAliasValue,
     parsedMail.to.value
   );
 
-  const pureAlias = decomposeUnpureAlias(unpureAlias).pureAlias;
+  const pureAlias = decomposeUnpureAliasValue(unpureAliasValue).pureAliasValue;
 
   const mailOptions: Mail.Options = {
     from: `${pureAlias}@${operationalDomain}`,
@@ -90,13 +93,22 @@ export const generateOutboundMailOptions = (
   return mailOptions;
 };
 
-// unpureAlias is likely in the format "originalAlias+base64EncodedData",
-// so we name it unpureAlias to prevent confusion.
-export default async (unpureAlias: string, parsedMail: ParsedMail) => {
-  console.log("Attempting to forward received email to personal email");
+// unpureAliasValue is likely in the format "originalAlias+base64EncodedData",
+// so we name it unpure to prevent confusion.
+export default async (unpureAliasValue: string, parsedMail: ParsedMail) => {
+  console.log("Attempting to forward received email to original sender");
+
+  const pureAliasValue = decomposeUnpureAliasValue(unpureAliasValue)
+    .pureAliasValue;
+
+  const alias = await Alias.getAlias(pureAliasValue);
+  if (alias === undefined) {
+    throw new Error(`Alias=${pureAliasValue} not found in database!`);
+  }
 
-  const mailOptions = generateOutboundMailOptions(unpureAlias, parsedMail);
+  const mailOptions = generateOutboundMailOptions(unpureAliasValue, parsedMail);
   await sendEmail(mailOptions);
+  await alias.didSendEmail();
 
   console.log("Successfully forwarded email to original sender");
 };
diff --git a/lib/getAliasDescription.ts b/lib/getAliasDescription.ts
deleted file mode 100644
index faede5c..0000000
--- a/lib/getAliasDescription.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { DynamoDB } from "aws-sdk";
-import config from "./config";
-
-export default async (alias: string): Promise<string> => {
-  const docClient = new DynamoDB.DocumentClient();
-  const params: DynamoDB.DocumentClient.GetItemInput = {
-    TableName: config.tableName,
-    Key: {
-      alias
-    }
-  };
-
-  const res = await docClient.get(params).promise();
-
-  if (!res.Item) {
-    throw new Error(`Alias=${alias} not found in database!`);
-  }
-
-  if (!res.Item.description) {
-    throw new Error(`Alias=${alias} does not have a "description" attribute!`);
-  }
-
-  return res.Item.description as string;
-};
diff --git a/lib/models/Alias.ts b/lib/models/Alias.ts
new file mode 100644
index 0000000..6ab68d4
--- /dev/null
+++ b/lib/models/Alias.ts
@@ -0,0 +1,216 @@
+import { DynamoDB } from "aws-sdk";
+import config from "../config";
+import generateAliasValue from "./generateAliasValue";
+
+export interface AliasData {
+  value: string;
+  description: string;
+  creationDate: Date;
+  countReceived: number;
+  countSent: number;
+  lastReceivedDate?: Date;
+  lastSentDate?: Date;
+}
+
+export interface GetAliasesResult {
+  aliases: Array<Alias>;
+  lastEvaluatedKey?: DynamoDB.Key;
+}
+
+export default class Alias implements AliasData {
+  private _value: string;
+  private _description: string;
+  private _creationDate: Date;
+  private _countReceived: number;
+  private _countSent: number;
+  private _lastReceivedDate?: Date;
+  private _lastSentDate?: Date;
+
+  private static client = new DynamoDB.DocumentClient();
+
+  // Public getters (readonly properties)
+
+  get value(): string {
+    return this._value;
+  }
+  get description(): string {
+    return this._description;
+  }
+  get creationDate(): Date {
+    return this._creationDate;
+  }
+  get countReceived(): number {
+    return this._countReceived;
+  }
+  get countSent(): number {
+    return this._countSent;
+  }
+  get lastReceivedDate(): Date | undefined {
+    return this._lastReceivedDate;
+  }
+  get lastSentDate(): Date | undefined {
+    return this._lastSentDate;
+  }
+
+  protected constructor(options: AliasData) {
+    this._value = options.value;
+    this._description = options.description;
+    this._creationDate = options.creationDate;
+    this._countReceived = options.countReceived;
+    this._countSent = options.countSent;
+    this._lastReceivedDate = options.lastReceivedDate;
+    this._lastSentDate = options.lastSentDate;
+  }
+
+  // Utils
+
+  private static generateGetParams(
+    aliasValue: string
+  ): DynamoDB.DocumentClient.GetItemInput {
+    return {
+      TableName: config.tableName,
+      Key: {
+        alias: aliasValue
+      }
+    };
+  }
+
+  private static generatePutParams(
+    alias: AliasData
+  ): DynamoDB.DocumentClient.PutItemInput {
+    return {
+      TableName: config.tableName,
+      Item: {
+        alias: alias.value,
+        description: alias.description,
+        creationDate: alias.creationDate.getTime(),
+        countReceived: alias.countReceived,
+        countSent: alias.countSent,
+        lastReceivedDate: alias.lastReceivedDate?.getTime(),
+        lastSentDate: alias.lastSentDate?.getTime()
+      }
+    };
+  }
+
+  private static convertRawDataToAlias(
+    rawAlias: DynamoDB.DocumentClient.AttributeMap
+  ): Alias {
+    return new Alias({
+      value: rawAlias.alias,
+      description: rawAlias.description,
+      creationDate: new Date(rawAlias.creationDate),
+      countReceived: rawAlias.countReceived,
+      countSent: rawAlias.countSent,
+      lastReceivedDate: rawAlias.lastReceivedDate
+        ? new Date(rawAlias.lastReceivedDate)
+        : undefined,
+      lastSentDate: rawAlias.lastSentDate
+        ? new Date(rawAlias.lastSentDate)
+        : undefined
+    });
+  }
+
+  // Dependency injection for testing
+  static useClient(client: DynamoDB.DocumentClient) {
+    this.client = client;
+  }
+
+  // Static methods
+
+  static async aliasExists(aliasValue: string): Promise<boolean> {
+    console.log(`Checking if alias of value=${aliasValue} exists`);
+
+    const possibleAlias = await Alias.getAlias(aliasValue);
+    return possibleAlias !== undefined;
+  }
+
+  static async generateAlias(description: string): Promise<Alias> {
+    console.log(`Generating new alias for description=${description}`);
+
+    let generatedAliasValue: string;
+    do {
+      generatedAliasValue = generateAliasValue();
+    } while (await Alias.aliasExists(generatedAliasValue));
+
+    console.log(
+      `Generated aliasValue=${generatedAliasValue} for description=${description}`
+    );
+
+    const alias = new Alias({
+      value: generatedAliasValue,
+      description,
+      creationDate: new Date(),
+      countReceived: 0,
+      countSent: 0
+    });
+
+    await Alias.putAlias(alias);
+
+    return alias;
+  }
+
+  static async getAlias(aliasValue: string): Promise<Alias | void> {
+    console.log(`Attempting to get alias of value=${aliasValue} from table`);
+
+    const params = Alias.generateGetParams(aliasValue);
+    const res = await Alias.client.get(params).promise();
+    const rawAlias = res.Item;
+
+    if (rawAlias === undefined) {
+      console.log(`Alias of value=${aliasValue} does not exist`);
+      return;
+    }
+
+    const alias = Alias.convertRawDataToAlias(rawAlias);
+
+    return alias;
+  }
+
+  static async getAllAliases(): Promise<GetAliasesResult> {
+    console.log("Scanning table for alias records");
+
+    const records: DynamoDB.DocumentClient.ScanOutput = await Alias.client
+      .scan({
+        TableName: config.tableName
+      })
+      .promise();
+
+    console.log("Scan operation completed");
+
+    if (records.Items === undefined || records.Items.length === 0) {
+      return {
+        aliases: []
+      };
+    }
+
+    return {
+      aliases: records.Items.map(Alias.convertRawDataToAlias),
+      lastEvaluatedKey: records.LastEvaluatedKey
+    };
+  }
+
+  static async putAlias(alias: AliasData): Promise<void> {
+    console.log(`Attempting to put alias=${alias} into table`);
+
+    const params = Alias.generatePutParams(alias);
+    await Alias.client.put(params).promise();
+
+    console.log("Successfully put alias into table");
+  }
+
+  // Instance methods
+
+  async didReceiveEmail(): Promise<void> {
+    this._countReceived += 1;
+    this._lastReceivedDate = new Date();
+
+    await Alias.putAlias(this);
+  }
+
+  async didSendEmail(): Promise<void> {
+    this._countSent += 1;
+    this._lastSentDate = new Date();
+
+    await Alias.putAlias(this);
+  }
+}
diff --git a/lib/models/__mocks__/Alias.ts b/lib/models/__mocks__/Alias.ts
new file mode 100644
index 0000000..b7ca97a
--- /dev/null
+++ b/lib/models/__mocks__/Alias.ts
@@ -0,0 +1,86 @@
+import { AliasData } from "../Alias";
+
+export const now = new Date();
+export const didReceiveEmailSpy = jest.fn();
+export const didSendEmailSpy = jest.fn();
+
+export default class MockAlias implements AliasData {
+  value: string;
+  description: string;
+  creationDate: Date;
+  countReceived: number;
+  countSent: number;
+  lastReceivedDate?: Date;
+  lastSentDate?: Date;
+
+  constructor(options: AliasData) {
+    this.value = options.value;
+    this.description = options.description;
+    this.creationDate = options.creationDate;
+    this.countReceived = options.countReceived;
+    this.countSent = options.countSent;
+    this.lastReceivedDate = options.lastReceivedDate;
+    this.lastSentDate = options.lastSentDate;
+  }
+
+  static generateAlias = jest.fn(async (description: string) => {
+    return new MockAlias({
+      value: "randomlygeneratedaliasvalue",
+      description,
+      creationDate: now,
+      countReceived: 0,
+      countSent: 0
+    });
+  });
+
+  static getAlias = jest.fn(async (aliasValue: string) => {
+    return new MockAlias({
+      value: aliasValue,
+      description: "test description",
+      creationDate: now,
+      countReceived: 0,
+      countSent: 0
+    });
+  });
+
+  static getAllAliases = jest.fn(async () => {
+    return {
+      aliases: [
+        new MockAlias({
+          value: "alias1",
+          description: "description1",
+          creationDate: new Date(),
+          countReceived: 1,
+          countSent: 1
+        }),
+        new MockAlias({
+          value: "alias2",
+          description: "description2",
+          creationDate: new Date(),
+          countReceived: 2,
+          countSent: 2
+        }),
+        new MockAlias({
+          value: "alias3",
+          description: "description3",
+          creationDate: new Date(),
+          countReceived: 3,
+          countSent: 3
+        })
+      ],
+      lastEvaluatedKey: {
+        alias: {
+          S: "alias3"
+        }
+      }
+    };
+  });
+
+  async didReceiveEmail() {
+    didReceiveEmailSpy();
+  }
+
+  async didSendEmail() {
+    didSendEmailSpy();
+  }
+}
diff --git a/lib/models/__mocks__/generateAliasValue.ts b/lib/models/__mocks__/generateAliasValue.ts
new file mode 100644
index 0000000..2081343
--- /dev/null
+++ b/lib/models/__mocks__/generateAliasValue.ts
@@ -0,0 +1,1 @@
+export default jest.fn(() => "randomlygeneratedaliasvalue");
diff --git a/lib/generateAlias.ts b/lib/models/generateAliasValue.ts
similarity index 100%
rename from lib/generateAlias.ts
rename to lib/models/generateAliasValue.ts
diff --git a/lib/senderAddressEncodeDecode.ts b/lib/senderAddressEncodeDecode.ts
index 1e8f17a..9ba4f28 100644
--- a/lib/senderAddressEncodeDecode.ts
+++ b/lib/senderAddressEncodeDecode.ts
@@ -3,7 +3,7 @@ import addrs from "email-addresses";
 import { operationalDomain } from "./env";
 
 export interface DecodedSenderAddress {
-  alias: string;
+  aliasValue: string;
   senderAddress: string;
 }
 
@@ -13,30 +13,32 @@ export interface DecodedSenderAddress {
  * original sender's email address.
  */
 
-const encodeUnpureAlias = (
-  alias: string,
+const encodeUnpureAliasValue = (
+  aliasValue: string,
   originalSenderAddress: string
 ): string => {
   const encodedSenderAddress = Base64.encode(originalSenderAddress);
-  return `${alias}+${encodedSenderAddress}`;
+  return `${aliasValue}+${encodedSenderAddress}`;
 };
 
 const encodeEmailAddress = (
-  alias: string,
+  aliasValue: string,
   originalSenderAddress: string
 ): string => {
-  return `${encodeUnpureAlias(
-    alias,
+  return `${encodeUnpureAliasValue(
+    aliasValue,
     originalSenderAddress
   )}@${operationalDomain}`;
 };
 
-const decodeUnpureAlias = (unpureAlias: string): DecodedSenderAddress => {
+const decodeUnpureAliasValue = (
+  unpureAliasValue: string
+): DecodedSenderAddress => {
   // This works because there is no "+" character in Base64 characters
-  const [alias, encodedSenderAddress] = unpureAlias.split("+");
+  const [aliasValue, encodedSenderAddress] = unpureAliasValue.split("+");
 
   return {
-    alias,
+    aliasValue: aliasValue,
     senderAddress:
       encodedSenderAddress === undefined
         ? "" // Don't attempt to decode undefined
@@ -51,12 +53,12 @@ const decodeEmailAddress = (
     encodedEmailAddress
   ) as addrs.ParsedMailbox;
 
-  return decodeUnpureAlias(parsed.local);
+  return decodeUnpureAliasValue(parsed.local);
 };
 
 export default {
-  encodeUnpureAlias,
+  encodeUnpureAliasValue,
   encodeEmailAddress,
-  decodeUnpureAlias,
+  decodeUnpureAliasValue,
   decodeEmailAddress
 };
diff --git a/lib/storeAliasDescriptionRecord.ts b/lib/storeAliasDescriptionRecord.ts
deleted file mode 100644
index 4c75343..0000000
--- a/lib/storeAliasDescriptionRecord.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { DynamoDB } from "aws-sdk";
-import config from "./config";
-
-export default async (
-  alias: string,
-  description: string,
-  client: DynamoDB.DocumentClient
-): Promise<void> => {
-  const docParams: DynamoDB.DocumentClient.PutItemInput = {
-    TableName: config.tableName,
-    Item: {
-      alias: alias,
-      description
-    }
-  };
-
-  await client.put(docParams).promise();
-};
diff --git a/scripts/migrate_v3.ts b/scripts/migrate_v3.ts
new file mode 100644
index 0000000..fc11fe2
--- /dev/null
+++ b/scripts/migrate_v3.ts
@@ -0,0 +1,66 @@
+/**
+ * v3.0.0 updates the shape of alias records.
+ * This script reshapes existing records for compatibility.
+ *
+ * By default, this script runs in the development stage.
+ * To run in production, run this script with environment variable STAGE=prod.
+ */
+
+import { DynamoDB } from "aws-sdk";
+import config from "../lib/config";
+
+// SCRIPT CONFIGURATION
+const REGION = ""; // Enter your DynamoDB table region here
+// END SCRIPT CONFIGURATION
+
+const client = new DynamoDB.DocumentClient({ region: REGION });
+const now = new Date();
+
+const reshapeItem = async (item: any): Promise<void> => {
+  const putParams: DynamoDB.DocumentClient.PutItemInput = {
+    TableName: config.tableName,
+    Item: {
+      alias: item.alias,
+      description: item.description,
+      creationDate: now.getTime(),
+      countReceived: 0,
+      countSent: 0,
+      lastReceivedDate: undefined,
+      lastSentDate: undefined
+    }
+  };
+
+  console.log(`Reshaping alias record of alias value=${item.alias}`);
+  await client.put(putParams).promise();
+  console.log("Reshaping successful");
+};
+
+const main = async (): Promise<void> => {
+  if (REGION === "") {
+    console.error("Set REGION script configuration variable first");
+    return;
+  }
+
+  const scanParams: DynamoDB.DocumentClient.ScanInput = {
+    TableName: config.tableName
+  };
+
+  console.log("Scanning table");
+  const scanResults: DynamoDB.DocumentClient.ScanOutput = await client
+    .scan(scanParams)
+    .promise();
+  console.log("Scan complete");
+
+  if (scanResults.Items === undefined) {
+    console.error("Items property missing in table scan results");
+    return;
+  }
+
+  scanResults.Items.forEach(async item => await reshapeItem(item));
+};
+
+console.log("Starting script");
+
+main()
+  .then(() => console.log("Script completed"))
+  .catch(err => console.error(`Error while running script: ${err}`));


Test patch:
diff --git a/tests/lib/aliasExists.test.ts b/tests/lib/aliasExists.test.ts
deleted file mode 100644
index 00e9f64..0000000
--- a/tests/lib/aliasExists.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import path from "path";
-import { DynamoDB } from "aws-sdk";
-import * as AWSMock from "aws-sdk-mock";
-import aliasExists from "../../lib/aliasExists";
-
-type Callback = (err: any, data: any) => void;
-
-beforeEach(() => {
-  // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
-  AWSMock.setSDK(path.resolve(__dirname, "../../node_modules/aws-sdk"));
-
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "get",
-    (params: any, callback: Callback) => {
-      if (params.Key.alias === "existingalias") {
-        callback(null, { Item: {} });
-      } else {
-        callback(null, {});
-      }
-    }
-  );
-});
-
-afterEach(() => {
-  AWSMock.restore();
-});
-
-it("should return true if alias exists in database", async () => {
-  const docClient = new DynamoDB.DocumentClient();
-  const res = await aliasExists("existingalias", docClient);
-  expect(res).toBe(true);
-});
-
-it("should return false if alias does not exist in database", async () => {
-  const docClient = new DynamoDB.DocumentClient();
-  const res = await aliasExists("nonexistentalias", docClient);
-  expect(res).toBe(false);
-});
diff --git a/tests/lib/commands/generate.test.ts b/tests/lib/commands/generate.test.ts
index 5f70275..b689c05 100644
--- a/tests/lib/commands/generate.test.ts
+++ b/tests/lib/commands/generate.test.ts
@@ -1,38 +1,14 @@
-import path from "path";
-import * as AWSMock from "aws-sdk-mock";
 import generate from "../../../lib/commands/generate";
+import Alias from "../../../lib/models/Alias";
 import sendEmail from "../../../lib/sendEmail";
 import { email, operationalDomain } from "../../../lib/env";
 import { Commands } from "../../../lib/commandSet";
 import generateTestEmail from "../../utils/generateTestEmail";
 
-jest.mock("../../../lib/aliasExists");
 jest.mock("../../../lib/sendEmail");
-jest.mock("../../../lib/generateAlias");
-
-type Callback = (err: any, data: any) => void;
-
-beforeEach(() => {
-  // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
-  AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
-});
-
-afterEach(() => {
-  AWSMock.restore();
-});
-
-it("should store the alias-description record and send a response email for a successful outcome", async () => {
-  const mockDocumentClient = jest.fn();
-
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "put",
-    (params: any, callback: Callback) => {
-      mockDocumentClient(params);
-      callback(null, null);
-    }
-  );
+jest.mock("../../../lib/models/Alias");
 
+it("should generate an alias with the description and send a response email", async () => {
   const testEmail = await generateTestEmail({
     to: [{ email: "test@domain.com" }],
     subject: "Some description"
@@ -40,17 +16,14 @@ it("should store the alias-description record and send a response email for a su
 
   await generate(testEmail);
 
-  expect(mockDocumentClient.mock.calls.length).toBe(1);
-  expect(mockDocumentClient.mock.calls[0][0].Item).toStrictEqual({
-    alias: "randomlygeneratedalias",
-    description: "Some description"
-  });
+  expect(Alias.generateAlias).toHaveBeenCalledTimes(1);
+  expect(Alias.generateAlias).toHaveBeenCalledWith("Some description");
 
   expect(sendEmail).toHaveBeenCalledTimes(1);
   expect(sendEmail).toHaveBeenCalledWith({
     from: `${Commands.Generate}@${operationalDomain}`,
     to: [email],
-    subject: "Generated alias: randomlygeneratedalias",
-    text: `You have generated randomlygeneratedalias@${operationalDomain} for "Some description".`
+    subject: "Generated alias: randomlygeneratedaliasvalue",
+    text: `You have generated randomlygeneratedaliasvalue@${operationalDomain} for "Some description".`
   });
 });
diff --git a/tests/lib/commands/info.test.ts b/tests/lib/commands/info.test.ts
new file mode 100644
index 0000000..fc318bd
--- /dev/null
+++ b/tests/lib/commands/info.test.ts
@@ -0,0 +1,51 @@
+import info from "../../../lib/commands/info";
+import sendEmail from "../../../lib/sendEmail";
+import { email, operationalDomain } from "../../../lib/env";
+import { Commands } from "../../../lib/commandSet";
+// @ts-ignore: We're using Jest's ES6 class mocks (https://jestjs.io/docs/en/es6-class-mocks)
+import Alias, { now } from "../../../lib/models/Alias";
+import generateTestEmail from "../../utils/generateTestEmail";
+
+jest.mock("../../../lib/models/Alias");
+jest.mock("../../../lib/sendEmail");
+const _sendEmail = sendEmail as jest.Mock;
+
+it("should send a response email with the usage stats of a valid alias", async () => {
+  const testEmail = await generateTestEmail({
+    to: [{ email: "test@domain.com" }],
+    subject: "validexistingalias"
+  });
+
+  await info(testEmail);
+
+  expect(Alias.getAlias).toHaveBeenCalledTimes(1);
+
+  expect(sendEmail).toHaveBeenCalledTimes(1);
+  expect(_sendEmail.mock.calls[0][0].from).toBe(
+    `${Commands.Info}@${operationalDomain}`
+  );
+  expect(_sendEmail.mock.calls[0][0].to).toStrictEqual(email);
+  expect(_sendEmail.mock.calls[0][0].subject).toBe(
+    `Info: validexistingalias@${operationalDomain}`
+  );
+  expect(_sendEmail.mock.calls[0][0].text).toContain(
+    `Alias: validexistingalias@${operationalDomain}\nDescription: test description\nCreated: ${now}\nEmails received: 0\nEmails sent: 0\nEmail last received on: -\nEmail last sent on: -\n`
+  );
+});
+
+it("should send a response email indicating alias does not exist", async () => {
+  jest.spyOn(Alias, "getAlias").mockImplementation(async () => {
+    return undefined;
+  });
+
+  const testEmail = await generateTestEmail({
+    to: [{ email: "test@domain.com" }],
+    subject: "validexistingalias"
+  });
+
+  await info(testEmail);
+
+  expect(_sendEmail.mock.calls[0][0].subject).toBe(
+    `Info: validexistingalias@${operationalDomain} does not exist`
+  );
+});
diff --git a/tests/lib/commands/list.test.ts b/tests/lib/commands/list.test.ts
index 615980d..1ef2588 100644
--- a/tests/lib/commands/list.test.ts
+++ b/tests/lib/commands/list.test.ts
@@ -1,155 +1,40 @@
-import path from "path";
-import * as AWSMock from "aws-sdk-mock";
-import { DynamoDB } from "aws-sdk";
 import list from "../../../lib/commands/list";
 import sendEmail from "../../../lib/sendEmail";
 import { email, operationalDomain } from "../../../lib/env";
 import { Commands } from "../../../lib/commandSet";
+import Alias from "../../../lib/models/Alias";
 
+jest.mock("../../../lib/models/Alias");
 jest.mock("../../../lib/sendEmail");
-
-type Callback = (err: any, data: any) => void;
-
-beforeEach(() => {
-  // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
-  AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
-});
-
-afterEach(() => {
-  AWSMock.restore();
-});
+const _sendEmail = sendEmail as jest.Mock;
 
 it("should send a response email with a list of alias-description records", async () => {
-  const mockDocumentClient = jest.fn();
-
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "scan",
-    (params: any, callback: Callback) => {
-      mockDocumentClient(params);
-      const records: DynamoDB.DocumentClient.ScanOutput = {
-        Items: [
-          {
-            alias: "alias1",
-            description: "description1"
-          },
-          {
-            alias: "alias2",
-            description: "description2"
-          },
-          {
-            alias: "alias3",
-            description: "description3"
-          }
-        ]
-      };
-      callback(null, records);
-    }
-  );
-
   await list();
 
-  expect(mockDocumentClient.mock.calls.length).toBe(1);
+  expect(Alias.getAllAliases).toHaveBeenCalledTimes(1);
 
   expect(sendEmail).toHaveBeenCalledTimes(1);
-  expect((sendEmail as jest.Mock).mock.calls[0][0].from).toBe(
+  expect(_sendEmail.mock.calls[0][0].from).toBe(
     `${Commands.List}@${operationalDomain}`
   );
-  expect((sendEmail as jest.Mock).mock.calls[0][0].to).toStrictEqual([email]);
-  expect((sendEmail as jest.Mock).mock.calls[0][0].subject).toContain(
-    "Alias list"
-  );
-  expect((sendEmail as jest.Mock).mock.calls[0][0].text).toBe(
+  expect(_sendEmail.mock.calls[0][0].to).toStrictEqual([email]);
+  expect(_sendEmail.mock.calls[0][0].subject).toContain("Alias list");
+  expect(_sendEmail.mock.calls[0][0].text).toContain(
     "Alias : Description\nalias1 : description1\nalias2 : description2\nalias3 : description3\n"
   );
-});
-
-it("should send a response email indicating no records found", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "scan",
-    (params: any, callback: Callback) => {
-      const records: DynamoDB.DocumentClient.ScanOutput = {
-        Items: []
-      };
-      callback(null, records);
-    }
-  );
-
-  await list();
-
-  expect((sendEmail as jest.Mock).mock.calls[0][0].text).toBe(
-    "No aliases found."
-  );
-});
-
-it("should notify user that there might be missing records", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "scan",
-    (params: any, callback: Callback) => {
-      const records: DynamoDB.DocumentClient.ScanOutput = {
-        Items: [
-          {
-            alias: "alias1",
-            description: "description1"
-          },
-          {
-            alias: "alias2",
-            description: "description2"
-          },
-          {
-            alias: "alias3",
-            description: "description3"
-          }
-        ],
-        LastEvaluatedKey: {
-          alias: "alias3",
-          description: "description3"
-        }
-      };
-      callback(null, records);
-    }
-  );
-
-  await list();
-
-  expect((sendEmail as jest.Mock).mock.calls[0][0].text).toContain(
+  expect(_sendEmail.mock.calls[0][0].text).toContain(
     "There might be more records in the results set. Check the logs and database for more information."
   );
 });
 
-it("should notify user that there is malformed data", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "scan",
-    (params: any, callback: Callback) => {
-      const records: DynamoDB.DocumentClient.ScanOutput = {
-        Items: [
-          {
-            alias: "alias1",
-            description: "description1"
-          },
-          {
-            alias: "alias2"
-          },
-          {
-            alias: "alias3",
-            description: "description3"
-          }
-        ],
-        LastEvaluatedKey: {
-          alias: "alias3",
-          description: "description3"
-        }
-      };
-      callback(null, records);
-    }
-  );
+it("should send a response email indicating no records found", async () => {
+  jest.spyOn(Alias, "getAllAliases").mockImplementation(async () => {
+    return {
+      aliases: []
+    };
+  });
 
   await list();
 
-  expect((sendEmail as jest.Mock).mock.calls[0][0].text).toContain(
-    "The database contains malformed records. Check the logs and database for more information."
-  );
+  expect(_sendEmail.mock.calls[0][0].text).toBe("No aliases found.");
 });
diff --git a/tests/lib/forwardInbound.test.ts b/tests/lib/forwardInbound.test.ts
index bc9028d..00a515c 100644
--- a/tests/lib/forwardInbound.test.ts
+++ b/tests/lib/forwardInbound.test.ts
@@ -1,15 +1,14 @@
 import { email as myEmail, operationalDomain } from "../../lib/env";
 import forwardInbound, { generateFromHeader } from "../../lib/forwardInbound";
-import * as getAliasDescription from "../../lib/getAliasDescription";
+// @ts-ignore: We're using Jest's ES6 class mocks (https://jestjs.io/docs/en/es6-class-mocks)
+import { didReceiveEmailSpy } from "../../lib/models/Alias";
 import sendEmail from "../../lib/sendEmail";
 import senderAddressEncodeDecode from "../../lib/senderAddressEncodeDecode";
 import assertEquivalentAttachments from "../utils/assertEquivalentAttachments";
 import generateTestEmail, { EMLFormatData } from "../utils/generateTestEmail";
 
+jest.mock("../../lib/models/Alias");
 jest.mock("../../lib/sendEmail");
-jest
-  .spyOn(getAliasDescription, "default")
-  .mockImplementation(async () => "test description");
 
 const _sendEmail = sendEmail as jest.Mock<any, any>;
 
@@ -80,17 +79,18 @@ it("should forward received email to personal email address", async () => {
   await forwardInbound(testAlias, testEmail1);
   expect(sendEmail).toHaveBeenCalledTimes(1);
   expect(_sendEmail.mock.calls[0][0].envelope).toStrictEqual(expectedEnvelope);
-  expect(_sendEmail.mock.calls[0][0].subject).toBe(
-    "[test description] Test subject"
-  );
+  expect(_sendEmail.mock.calls[0][0].subject).toBe("Test subject");
+  expect(didReceiveEmailSpy).toHaveBeenCalledTimes(1);
 
   await forwardInbound(testAlias, testEmail2);
   expect(sendEmail).toHaveBeenCalledTimes(2);
   expect(_sendEmail.mock.calls[1][0].envelope).toStrictEqual(expectedEnvelope);
+  expect(didReceiveEmailSpy).toHaveBeenCalledTimes(2);
 
   await forwardInbound(testAlias, testEmail3);
   expect(sendEmail).toHaveBeenCalledTimes(3);
   expect(_sendEmail.mock.calls[2][0].envelope).toStrictEqual(expectedEnvelope);
+  expect(didReceiveEmailSpy).toHaveBeenCalledTimes(3);
 });
 
 it(`should encode the original sender's email address in the "from" header of the forwarded email`, async () => {
diff --git a/tests/lib/forwardOutbound.test.ts b/tests/lib/forwardOutbound.test.ts
index 7b7cc31..bd6cf5c 100644
--- a/tests/lib/forwardOutbound.test.ts
+++ b/tests/lib/forwardOutbound.test.ts
@@ -1,14 +1,17 @@
 import { EmailAddress } from "mailparser";
 import { operationalDomain } from "../../lib/env";
 import sendEmail from "../../lib/sendEmail";
+// @ts-ignore: We're using Jest's ES6 class mocks (https://jestjs.io/docs/en/es6-class-mocks)
+import { didSendEmailSpy } from "../../lib/models/Alias";
 import senderAddressEncodeDecode from "../../lib/senderAddressEncodeDecode";
 import forwardOutbound, {
-  decomposeUnpureAlias,
+  decomposeUnpureAliasValue,
   generateOriginalSenderEmailAddress
 } from "../../lib/forwardOutbound";
 import assertEquivalentAttachments from "../utils/assertEquivalentAttachments";
 import generateTestEmail, { EMLFormatData } from "../utils/generateTestEmail";
 
+jest.mock("../../lib/models/Alias");
 jest.mock("../../lib/sendEmail");
 
 const testAlias = "testAlias";
@@ -39,7 +42,10 @@ it("should send outbound email from alias to original sender", async () => {
   const testEmail = await generateTestEmail(testEmailData1);
 
   await forwardOutbound(
-    senderAddressEncodeDecode.encodeUnpureAlias(testAlias, senderEmailAddress),
+    senderAddressEncodeDecode.encodeUnpureAliasValue(
+      testAlias,
+      senderEmailAddress
+    ),
     testEmail
   );
 
@@ -55,12 +61,13 @@ it("should send outbound email from alias to original sender", async () => {
     html: "Test html\n",
     attachments: []
   });
+  expect(didSendEmailSpy).toHaveBeenCalledTimes(1);
 });
 
 it("should throw if original sender address is missing", () => {
   expect(() => {
-    decomposeUnpureAlias(
-      senderAddressEncodeDecode.encodeUnpureAlias("testAlias", "")
+    decomposeUnpureAliasValue(
+      senderAddressEncodeDecode.encodeUnpureAliasValue("testAlias", "")
     );
   }).toThrow(
     new Error(
@@ -84,7 +91,10 @@ it("should only generate original sender's email address", () => {
   ];
 
   const generatedRecipient = generateOriginalSenderEmailAddress(
-    senderAddressEncodeDecode.encodeUnpureAlias(testAlias, senderEmailAddress),
+    senderAddressEncodeDecode.encodeUnpureAliasValue(
+      testAlias,
+      senderEmailAddress
+    ),
     emails
   );
 
@@ -119,7 +129,10 @@ it(`should discard all other recipients on the "to" and "cc" header lists`, asyn
   });
 
   await forwardOutbound(
-    senderAddressEncodeDecode.encodeUnpureAlias(testAlias, senderEmailAddress),
+    senderAddressEncodeDecode.encodeUnpureAliasValue(
+      testAlias,
+      senderEmailAddress
+    ),
     testEmail
   );
 
@@ -135,13 +148,17 @@ it(`should discard all other recipients on the "to" and "cc" header lists`, asyn
     html: "Test html\n",
     attachments: []
   });
+  expect(didSendEmailSpy).toHaveBeenCalledTimes(1);
 });
 
 it("should forward attachments to original sender", async () => {
   const testEmail = await generateTestEmail(testEmailData2);
 
   await forwardOutbound(
-    senderAddressEncodeDecode.encodeUnpureAlias(testAlias, senderEmailAddress),
+    senderAddressEncodeDecode.encodeUnpureAliasValue(
+      testAlias,
+      senderEmailAddress
+    ),
     testEmail
   );
 
@@ -154,6 +171,7 @@ it("should forward attachments to original sender", async () => {
       _sendEmail.mock.calls[0][0].attachments[0]
     )
   ).toBe(true);
+  expect(didSendEmailSpy).toHaveBeenCalledTimes(1);
 });
 
 /* Security-related tests */
diff --git a/tests/lib/getAliasDesciption.test.ts b/tests/lib/getAliasDesciption.test.ts
deleted file mode 100644
index b3e9d7a..0000000
--- a/tests/lib/getAliasDesciption.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import path from "path";
-import * as AWSMock from "aws-sdk-mock";
-import getAliasDescription from "../../lib/getAliasDescription";
-
-type Callback = (err: any, data: any) => void;
-
-beforeEach(() => {
-  // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
-  AWSMock.setSDK(path.resolve(__dirname, "../../node_modules/aws-sdk"));
-});
-
-afterEach(() => {
-  AWSMock.restore("DynamoDB.DocumentClient");
-});
-
-it("should return the description for a valid alias", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "get",
-    (params: any, callback: Callback) => {
-      callback(null, {
-        Item: { alias: "alias1", description: "description1" }
-      });
-    }
-  );
-
-  await expect(getAliasDescription("alias1")).resolves.toBe("description1");
-});
-
-it("should throw an error if alias doesn't exist", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "get",
-    (params: any, callback: Callback) => {
-      callback(null, {});
-    }
-  );
-
-  await expect(getAliasDescription("alias2")).rejects.toThrow(
-    "Alias=alias2 not found in database!"
-  );
-});
-
-it("should throw an error if the document has no `description` attribute", async () => {
-  AWSMock.mock(
-    "DynamoDB.DocumentClient",
-    "get",
-    (params: any, callback: Callback) => {
-      callback(null, { Item: { alias: "alias1" } });
-    }
-  );
-
-  await expect(getAliasDescription("alias1")).rejects.toThrow(
-    'Alias=alias1 does not have a "description" attribute'
-  );
-});
diff --git a/tests/lib/models/Alias.test.ts b/tests/lib/models/Alias.test.ts
new file mode 100644
index 0000000..5091ed3
--- /dev/null
+++ b/tests/lib/models/Alias.test.ts
@@ -0,0 +1,316 @@
+import path from "path";
+import { DynamoDB } from "aws-sdk";
+import * as AWSMock from "aws-sdk-mock";
+import Alias, { AliasData } from "../../../lib/models/Alias";
+import generateAliasValue from "../../../lib/models/generateAliasValue";
+
+jest.mock("../../../lib/models/generateAliasValue");
+
+type Callback = (err: any, data: any) => void;
+
+describe("Test aliasExists", () => {
+  beforeEach(() => {
+    // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
+    AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
+
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "get",
+      (params: any, callback: Callback) => {
+        if (params.Key.alias === "existingalias") {
+          callback(null, { Item: {} });
+        } else {
+          callback(null, {});
+        }
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+  });
+
+  afterEach(() => {
+    AWSMock.restore();
+  });
+
+  it("should return true if alias exists in database", async () => {
+    const res = await Alias.aliasExists("existingalias");
+    expect(res).toBe(true);
+  });
+
+  it("should return false if alias does not exist in database", async () => {
+    const res = await Alias.aliasExists("nonexistentalias");
+    expect(res).toBe(false);
+  });
+});
+
+describe("Test generateAlias", () => {
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  it("should generate alias values until there is no collision, then put to table", async () => {
+    jest
+      .spyOn(Alias, "aliasExists")
+      .mockImplementationOnce(async () => true)
+      .mockImplementationOnce(async () => true)
+      .mockImplementationOnce(async () => false);
+
+    const putSpy = jest
+      .spyOn(Alias, "putAlias")
+      .mockImplementation(async () => {});
+
+    await Alias.generateAlias("some description");
+
+    expect(generateAliasValue).toHaveBeenCalledTimes(3);
+    expect(putSpy).toHaveBeenCalledTimes(1);
+    expect(putSpy.mock.calls[0][0].value).toBe("randomlygeneratedaliasvalue");
+    expect(putSpy.mock.calls[0][0].description).toBe("some description");
+    expect(putSpy.mock.calls[0][0].countReceived).toBe(0);
+    expect(putSpy.mock.calls[0][0].countSent).toBe(0);
+  });
+});
+
+describe("Test getAllAliases", () => {
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  beforeEach(() => {
+    // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
+    AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
+  });
+
+  afterEach(() => {
+    AWSMock.restore();
+  });
+
+  it("should map raw results into alias objects", async () => {
+    const creationDate1 = new Date();
+    const rawResult1 = {
+      alias: "value1",
+      description: "description1",
+      creationDate: creationDate1.getTime(),
+      countReceived: 1,
+      countSent: 2
+    };
+
+    const creationDate2 = new Date();
+    const lastReceivedDate2 = new Date();
+    const rawResult2 = {
+      alias: "value2",
+      description: "description2",
+      creationDate: creationDate2.getTime(),
+      countReceived: 3,
+      countSent: 4,
+      lastReceivedDate: lastReceivedDate2.getTime()
+    };
+
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "scan",
+      (params: any, callback: Callback) => {
+        callback(null, { Items: [rawResult1, rawResult2] });
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+
+    const res = await Alias.getAllAliases();
+    expect(res.aliases[0]).toBeInstanceOf(Alias);
+    expect(res.aliases[0]).toBeInstanceOf(Alias);
+    expect(res).toMatchObject({
+      aliases: [
+        {
+          value: "value1",
+          description: "description1",
+          creationDate: creationDate1,
+          countReceived: 1,
+          countSent: 2,
+          lastReceivedDate: undefined,
+          lastSentDate: undefined
+        },
+        {
+          value: "value2",
+          description: "description2",
+          creationDate: creationDate2,
+          countReceived: 3,
+          countSent: 4,
+          lastReceivedDate: lastReceivedDate2,
+          lastSentDate: undefined
+        }
+      ]
+    });
+  });
+
+  it("should return an empty results object if there are no aliases", async () => {
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "scan",
+      (params: any, callback: Callback) => {
+        callback(null, { Items: [] });
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+
+    const res = await Alias.getAllAliases();
+
+    expect(res).toStrictEqual({
+      aliases: []
+    });
+  });
+
+  it("should set last evaluated key if it is present", async () => {
+    const testAlias = {
+      alias: "value",
+      description: "description",
+      creationDate: new Date().getTime(),
+      countReceived: 1,
+      countSent: 2,
+      lastReceivedDate: new Date().getTime()
+    };
+
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "scan",
+      (params: any, callback: Callback) => {
+        callback(null, {
+          Items: [testAlias],
+          LastEvaluatedKey: {
+            alias: {
+              S: "foo"
+            }
+          }
+        });
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+
+    const res = await Alias.getAllAliases();
+
+    expect(res.lastEvaluatedKey).toStrictEqual({ alias: { S: "foo" } });
+  });
+});
+
+describe("Test putAlias", () => {
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  beforeEach(() => {
+    // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
+    AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
+  });
+
+  afterEach(() => {
+    AWSMock.restore();
+  });
+
+  it("should correctly store the alias in the table", async () => {
+    const spy = jest.fn();
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "put",
+      (params: any, callback: Callback) => {
+        spy(params);
+        callback(null, null);
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+
+    const now = new Date();
+    const testAlias: AliasData = {
+      value: "aliasvalue",
+      description: "aliasdescription",
+      creationDate: now,
+      countReceived: 10,
+      countSent: 20
+    };
+
+    await Alias.putAlias(testAlias);
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(spy.mock.calls[0][0].Item).toStrictEqual({
+      alias: "aliasvalue",
+      description: "aliasdescription",
+      creationDate: now.getTime(),
+      countReceived: 10,
+      countSent: 20,
+      lastReceivedDate: undefined,
+      lastSentDate: undefined
+    });
+  });
+});
+
+describe("Test didReceiveEmail and didSendEmail", () => {
+  beforeEach(() => {
+    // Fix: https://github.com/dwyl/aws-sdk-mock/issues/145
+    AWSMock.setSDK(path.resolve(__dirname, "../../../node_modules/aws-sdk"));
+
+    AWSMock.mock(
+      "DynamoDB.DocumentClient",
+      "get",
+      (params: any, callback: Callback) => {
+        callback(null, {
+          Item: {
+            value: "value",
+            description: "description",
+            creationDate: new Date().getTime(),
+            countReceived: 0,
+            countSent: 0
+          }
+        });
+      }
+    );
+
+    const client = new DynamoDB.DocumentClient();
+    Alias.useClient(client);
+  });
+
+  afterEach(() => {
+    AWSMock.restore();
+  });
+
+  it("should update alias's receipt properties and put into table", async () => {
+    const putSpy = jest
+      .spyOn(Alias, "putAlias")
+      .mockImplementation(async () => {});
+
+    const testAlias = (await Alias.getAlias("value")) as Alias;
+
+    expect(testAlias.countReceived).toBe(0);
+    expect(testAlias.lastReceivedDate).toBeUndefined();
+    expect(putSpy).toHaveBeenCalledTimes(0);
+
+    testAlias.didReceiveEmail();
+
+    expect(testAlias.countReceived).toBe(1);
+    expect(testAlias.lastReceivedDate).not.toBeUndefined();
+    expect(putSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it("should update alias's sent properties and put into table", async () => {
+    const putSpy = jest
+      .spyOn(Alias, "putAlias")
+      .mockImplementation(async () => {});
+
+    const testAlias = (await Alias.getAlias("value")) as Alias;
+
+    expect(testAlias.countSent).toBe(0);
+    expect(testAlias.lastSentDate).toBeUndefined();
+    expect(putSpy).toHaveBeenCalledTimes(0);
+
+    testAlias.didSendEmail();
+
+    expect(testAlias.countSent).toBe(1);
+    expect(testAlias.lastSentDate).not.toBeUndefined();
+    expect(putSpy).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/tests/lib/senderAddressEncodeDecode.test.ts b/tests/lib/senderAddressEncodeDecode.test.ts
index c91e8bf..da16e76 100644
--- a/tests/lib/senderAddressEncodeDecode.test.ts
+++ b/tests/lib/senderAddressEncodeDecode.test.ts
@@ -10,7 +10,7 @@ test("encode alias and email address", () => {
   );
   const decoded = senderAddressEncodeDecode.decodeEmailAddress(encoded);
 
-  expect(decoded.alias).toBe(alias);
+  expect(decoded.aliasValue).toBe(alias);
   expect(decoded.senderAddress).toBe(senderAddress);
 });
 
@@ -18,6 +18,6 @@ test("encode alias and empty email address", () => {
   const encoded = senderAddressEncodeDecode.encodeEmailAddress(alias, "");
   const decoded = senderAddressEncodeDecode.decodeEmailAddress(encoded);
 
-  expect(decoded.alias).toBe(alias);
+  expect(decoded.aliasValue).toBe(alias);
   expect(decoded.senderAddress).toBe("");
 });