Repository: fterh/heimdall (ts)

Issue:
Reply to commands instead of sending a new email
In Gmail (and probably most clients), this creates a conversation thread for easier tracking.

Pull request description:
Support replying to commands in threaded conversations
Closes #42.

Patch:
diff --git a/lib/commands/generate.ts b/lib/commands/generate.ts
index 87028bf..3b3f5c8 100644
--- a/lib/commands/generate.ts
+++ b/lib/commands/generate.ts
@@ -2,16 +2,25 @@ import { ParsedMail } from "mailparser";
 import { email, operationalDomain } from "../env";
 import { Commands } from "../commandSet";
 import Alias from "../models/Alias";
+import generateReplyEmail from "../generateReplyEmail";
 import sendEmail from "../sendEmail";
 
 export default async (parsedMail: ParsedMail): Promise<void> => {
   const description = parsedMail.subject || "No description";
   const alias = await Alias.generateAlias(description);
 
-  await sendEmail({
-    from: `${Commands.Generate}@${operationalDomain}`,
-    to: [email],
-    subject: `Generated alias: ${alias.value}`,
-    text: `You have generated ${alias.value}@${operationalDomain} for "${alias.description}".`
-  });
+  await sendEmail(
+    generateReplyEmail(
+      {
+        from: {
+          name: "Generate",
+          address: `${Commands.Generate}@${operationalDomain}`
+        },
+        to: [email],
+        subject: parsedMail.subject,
+        text: `You have generated ${alias.value}@${operationalDomain} for "${alias.description}".`
+      },
+      parsedMail
+    )
+  );
 };
diff --git a/lib/commands/index.ts b/lib/commands/index.ts
index a2e1f8a..eb54e2d 100644
--- a/lib/commands/index.ts
+++ b/lib/commands/index.ts
@@ -27,7 +27,7 @@ export default async (command: string, parsedMail: ParsedMail) => {
 
     case Commands.List:
       console.log("Invoking list command");
-      await list();
+      await list(parsedMail);
       break;
 
     case Commands.Remove:
diff --git a/lib/commands/info.ts b/lib/commands/info.ts
index ecdbcfc..2833d0f 100644
--- a/lib/commands/info.ts
+++ b/lib/commands/info.ts
@@ -2,6 +2,7 @@ import { ParsedMail } from "mailparser";
 import { Commands } from "../commandSet";
 import { email, operationalDomain } from "../env";
 import Alias from "../models/Alias";
+import generateReplyEmail from "../generateReplyEmail";
 import sendEmail from "../sendEmail";
 
 const commandEmailAddress = `${Commands.Info}@${operationalDomain}`;
@@ -45,10 +46,18 @@ export default async (parsedMail: ParsedMail): Promise<void> => {
     return;
   }
 
-  await sendEmail({
-    from: commandEmailAddress,
-    to: email,
-    subject: `Info: ${aliasValue}@${operationalDomain}`,
-    text: prepareAliasInfoText(alias)
-  });
+  await sendEmail(
+    generateReplyEmail(
+      {
+        from: {
+          name: "Info",
+          address: commandEmailAddress
+        },
+        to: email,
+        subject: parsedMail.subject,
+        text: prepareAliasInfoText(alias)
+      },
+      parsedMail
+    )
+  );
 };
diff --git a/lib/commands/list.ts b/lib/commands/list.ts
index 8a76f2a..7230849 100644
--- a/lib/commands/list.ts
+++ b/lib/commands/list.ts
@@ -1,9 +1,11 @@
+import { ParsedMail } from "mailparser";
 import { email, operationalDomain } from "../env";
 import { Commands } from "../commandSet";
 import Alias from "../models/Alias";
+import generateReplyEmail from "../generateReplyEmail";
 import sendEmail from "../sendEmail";
 
-export default async (): Promise<void> => {
+export default async (parsedMail: ParsedMail): Promise<void> => {
   const getAliasesResults = await Alias.getAllAliases();
 
   if (getAliasesResults.aliases.length === 0) {
@@ -26,10 +28,18 @@ export default async (): Promise<void> => {
       "There might be more records in the results set. Check the logs and database for more information.";
   }
 
-  await sendEmail({
-    from: `${Commands.List}@${operationalDomain}`,
-    to: [email],
-    subject: `Alias list (generated on: ${new Date()})`,
-    text: output
-  });
+  await sendEmail(
+    generateReplyEmail(
+      {
+        from: {
+          name: "List",
+          address: `${Commands.List}@${operationalDomain}`
+        },
+        to: [email],
+        subject: parsedMail.subject,
+        text: output
+      },
+      parsedMail
+    )
+  );
 };
diff --git a/lib/commands/remove.ts b/lib/commands/remove.ts
index 822671e..4209623 100644
--- a/lib/commands/remove.ts
+++ b/lib/commands/remove.ts
@@ -3,6 +3,7 @@ import { ParsedMail } from "mailparser";
 import config from "../config";
 import { email, operationalDomain } from "../env";
 import { Commands } from "../commandSet";
+import generateReplyEmail from "../generateReplyEmail";
 import sendEmail from "../sendEmail";
 
 export default async (parsedMail: ParsedMail): Promise<void> => {
@@ -20,10 +21,18 @@ export default async (parsedMail: ParsedMail): Promise<void> => {
   await docClient.delete(params).promise();
   console.log("Deletion successful");
 
-  await sendEmail({
-    from: `${Commands.Remove}@${operationalDomain}`,
-    to: [email],
-    subject: `Delete alias ${providedAlias}`,
-    text: "Deletion completed."
-  });
+  await sendEmail(
+    generateReplyEmail(
+      {
+        from: {
+          name: "Remove",
+          address: `${Commands.Remove}@${operationalDomain}`
+        },
+        to: [email],
+        subject: parsedMail.subject,
+        text: "Deletion completed."
+      },
+      parsedMail
+    )
+  );
 };
diff --git a/lib/generateReplyEmail.ts b/lib/generateReplyEmail.ts
new file mode 100644
index 0000000..8c15972
--- /dev/null
+++ b/lib/generateReplyEmail.ts
@@ -0,0 +1,30 @@
+import { ParsedMail } from "mailparser";
+import Mail from "nodemailer/lib/mailer";
+
+/**
+ * Generates an email in reply to another email by
+ * utilizing the In-Reply-To and References headers
+ * to make threaded conversations possible in email clients.
+ */
+export default (
+  mailOptions: Mail.Options,
+  replyTo: ParsedMail
+): Mail.Options => {
+  console.log("Generating reply email");
+
+  const messageId = replyTo.messageId;
+
+  if (!messageId) {
+    console.log("Message-ID not found; returning original mailOptions object");
+    return mailOptions;
+  }
+
+  let references = replyTo.references || [];
+  references.push(messageId);
+
+  return {
+    ...mailOptions,
+    inReplyTo: messageId,
+    references: references
+  };
+};


Test patch:
diff --git a/tests/lib/commands/generate.test.ts b/tests/lib/commands/generate.test.ts
index 178a034..e74bc47 100644
--- a/tests/lib/commands/generate.test.ts
+++ b/tests/lib/commands/generate.test.ts
@@ -9,10 +9,13 @@ jest.mock("../../../lib/sendEmail");
 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"
-  });
+  const testEmail = await generateTestEmail(
+    {
+      to: [{ email: "test@domain.com" }],
+      subject: "Some description"
+    },
+    "messageId"
+  );
 
   await generate(testEmail);
 
@@ -21,9 +24,14 @@ it("should generate an alias with the description and send a response email", as
 
   expect(sendEmail).toHaveBeenCalledTimes(1);
   expect(sendEmail).toHaveBeenCalledWith({
-    from: `${Commands.Generate}@${operationalDomain}`,
+    from: {
+      name: "Generate",
+      address: `${Commands.Generate}@${operationalDomain}`
+    },
     to: [email],
-    subject: "Generated alias: randomlygeneratedaliasvalue",
+    inReplyTo: "messageId",
+    references: ["messageId"],
+    subject: "Some description",
     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
index 0526798..b138837 100644
--- a/tests/lib/commands/info.test.ts
+++ b/tests/lib/commands/info.test.ts
@@ -11,23 +11,27 @@ 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"
-  });
+  const testEmail = await generateTestEmail(
+    {
+      to: [{ email: "test@domain.com" }],
+      subject: "validexistingalias"
+    },
+    "messageId"
+  );
 
   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].from).toStrictEqual({
+    name: "Info",
+    address: `${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].inReplyTo).toBe("messageId");
+  expect(_sendEmail.mock.calls[0][0].references).toStrictEqual(["messageId"]);
+  expect(_sendEmail.mock.calls[0][0].subject).toBe("validexistingalias");
   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`
   );
diff --git a/tests/lib/commands/list.test.ts b/tests/lib/commands/list.test.ts
index 1ef2588..6b58334 100644
--- a/tests/lib/commands/list.test.ts
+++ b/tests/lib/commands/list.test.ts
@@ -3,22 +3,34 @@ import sendEmail from "../../../lib/sendEmail";
 import { email, operationalDomain } from "../../../lib/env";
 import { Commands } from "../../../lib/commandSet";
 import Alias from "../../../lib/models/Alias";
+import generateTestEmail from "../../utils/generateTestEmail";
 
 jest.mock("../../../lib/models/Alias");
 jest.mock("../../../lib/sendEmail");
 const _sendEmail = sendEmail as jest.Mock;
 
+const testEmailPromise = generateTestEmail(
+  {
+    to: [{ email: "test@domain.com" }],
+    subject: "validexistingalias"
+  },
+  "messageId"
+);
+
 it("should send a response email with a list of alias-description records", async () => {
-  await list();
+  await list(await testEmailPromise);
 
   expect(Alias.getAllAliases).toHaveBeenCalledTimes(1);
 
   expect(sendEmail).toHaveBeenCalledTimes(1);
-  expect(_sendEmail.mock.calls[0][0].from).toBe(
-    `${Commands.List}@${operationalDomain}`
-  );
+  expect(_sendEmail.mock.calls[0][0].from).toStrictEqual({
+    name: "List",
+    address: `${Commands.List}@${operationalDomain}`
+  });
   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].inReplyTo).toBe("messageId");
+  expect(_sendEmail.mock.calls[0][0].references).toStrictEqual(["messageId"]);
+  expect(_sendEmail.mock.calls[0][0].subject).toBe("validexistingalias");
   expect(_sendEmail.mock.calls[0][0].text).toContain(
     "Alias : Description\nalias1 : description1\nalias2 : description2\nalias3 : description3\n"
   );
@@ -34,7 +46,7 @@ it("should send a response email indicating no records found", async () => {
     };
   });
 
-  await list();
+  await list(await testEmailPromise);
 
   expect(_sendEmail.mock.calls[0][0].text).toBe("No aliases found.");
 });
diff --git a/tests/lib/commands/remove.test.ts b/tests/lib/commands/remove.test.ts
index 0b5edaf..0f8b65d 100644
--- a/tests/lib/commands/remove.test.ts
+++ b/tests/lib/commands/remove.test.ts
@@ -31,10 +31,13 @@ it("should delete the provided alias", async () => {
     }
   );
 
-  const testEmail = await generateTestEmail({
-    to: [{ email: "test@domain.com" }],
-    subject: "abandonedalias"
-  });
+  const testEmail = await generateTestEmail(
+    {
+      to: [{ email: "test@domain.com" }],
+      subject: "abandonedalias"
+    },
+    "messageId"
+  );
 
   await remove(testEmail);
 
@@ -45,9 +48,14 @@ it("should delete the provided alias", async () => {
 
   expect(sendEmail).toHaveBeenCalledTimes(1);
   expect(sendEmail).toHaveBeenCalledWith({
-    from: `${Commands.Remove}@${operationalDomain}`,
+    from: {
+      name: "Remove",
+      address: `${Commands.Remove}@${operationalDomain}`
+    },
     to: [email],
-    subject: "Delete alias abandonedalias",
+    inReplyTo: "messageId",
+    references: ["messageId"],
+    subject: "abandonedalias",
     text: "Deletion completed."
   });
 });
diff --git a/tests/lib/generateReplyEmail.test.ts b/tests/lib/generateReplyEmail.test.ts
new file mode 100644
index 0000000..7f7fcb8
--- /dev/null
+++ b/tests/lib/generateReplyEmail.test.ts
@@ -0,0 +1,36 @@
+import generateReplyEmail from "../../lib/generateReplyEmail";
+import generateTestEmail from "../utils/generateTestEmail";
+
+const testEmailData = {
+  from: "sender@domain.com",
+  to: "recipient@domain.com"
+};
+
+it("should generate the In-Reply-To and References headers in response to a new email", async () => {
+  const testEmail = await generateTestEmail(testEmailData, "messageId");
+  const replyEmail = generateReplyEmail({}, testEmail);
+
+  expect(replyEmail.inReplyTo).toBe("messageId");
+  expect(replyEmail.references).toStrictEqual(["messageId"]);
+});
+
+it("should append the Message-ID to an existing References header", async () => {
+  const testEmail = await generateTestEmail(testEmailData, "messageId", [
+    "referencedMessageId"
+  ]);
+  const replyEmail = generateReplyEmail({}, testEmail);
+  console.log(replyEmail);
+
+  expect(replyEmail.inReplyTo).toBe("messageId");
+  expect(replyEmail.references).toStrictEqual([
+    "referencedMessageId",
+    "messageId"
+  ]);
+});
+
+it("should return gracefully if incoming email has no Message-ID header", async () => {
+  const testEmail = await generateTestEmail(testEmailData);
+  const replyEmail = generateReplyEmail({}, testEmail);
+
+  expect(replyEmail).toStrictEqual({});
+});
diff --git a/tests/utils/generateTestEmail.ts b/tests/utils/generateTestEmail.ts
index c8a5a3f..1e9568f 100644
--- a/tests/utils/generateTestEmail.ts
+++ b/tests/utils/generateTestEmail.ts
@@ -25,12 +25,20 @@ export interface EMLFormatData {
   attachments?: Array<EMLFormatDataAttachment>;
 }
 
-export default async (data: EMLFormatData): Promise<ParsedMail> => {
+export default async (
+  data: EMLFormatData,
+  messageId: string | undefined = undefined,
+  references: Array<string> | undefined = undefined
+): Promise<ParsedMail> => {
   let testEml = "";
   emlformat.build(data, (err: Error | null, eml: string) => {
     if (err) return console.error(err);
     testEml = eml;
   });
 
-  return await simpleParser(testEml);
+  const testEmail = await simpleParser(testEml);
+  testEmail.messageId = messageId;
+  testEmail.references = references;
+
+  return testEmail;
 };