diff --git a/src/data/GuildReminders.ts b/src/data/GuildReminders.ts
new file mode 100644
index 00000000..079af4f1
--- /dev/null
+++ b/src/data/GuildReminders.ts
@@ -0,0 +1,46 @@
+import { BaseRepository } from "./BaseRepository";
+import { getRepository, Repository } from "typeorm";
+import { Reminder } from "./entities/Reminder";
+
+export class GuildReminders extends BaseRepository {
+  private reminders: Repository<Reminder>;
+
+  constructor(guildId) {
+    super(guildId);
+    this.reminders = getRepository(Reminder);
+  }
+
+  async getDueReminders(): Promise<Reminder[]> {
+    return this.reminders
+      .createQueryBuilder()
+      .where("guild_id = :guildId", { guildId: this.guildId })
+      .andWhere("remind_at <= NOW()")
+      .getMany();
+  }
+
+  async getRemindersByUserId(userId: string): Promise<Reminder[]> {
+    return this.reminders.find({
+      where: {
+        guild_id: this.guildId,
+        user_id: userId,
+      },
+    });
+  }
+
+  async delete(id) {
+    await this.reminders.delete({
+      guild_id: this.guildId,
+      id,
+    });
+  }
+
+  async add(userId: string, channelId: string, remindAt: string, body: string) {
+    await this.reminders.insert({
+      guild_id: this.guildId,
+      user_id: userId,
+      channel_id: channelId,
+      remind_at: remindAt,
+      body,
+    });
+  }
+}
diff --git a/src/data/entities/Reminder.ts b/src/data/entities/Reminder.ts
new file mode 100644
index 00000000..a069ddcf
--- /dev/null
+++ b/src/data/entities/Reminder.ts
@@ -0,0 +1,18 @@
+import { Entity, Column, PrimaryColumn } from "typeorm";
+
+@Entity("reminders")
+export class Reminder {
+  @Column()
+  @PrimaryColumn()
+  id: number;
+
+  @Column() guild_id: string;
+
+  @Column() user_id: string;
+
+  @Column() channel_id: string;
+
+  @Column() remind_at: string;
+
+  @Column() body: string;
+}
diff --git a/src/index.ts b/src/index.ts
index b82c5e2a..6a32644d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,9 +70,10 @@ import { MutesPlugin } from "./plugins/Mutes";
 import { SlowmodePlugin } from "./plugins/Slowmode";
 import { StarboardPlugin } from "./plugins/Starboard";
 import { NameHistoryPlugin } from "./plugins/NameHistory";
-import { AutoReactions } from "./plugins/AutoReactions";
-import { PingableRoles } from "./plugins/PingableRoles";
-import { SelfGrantableRoles } from "./plugins/SelfGrantableRoles";
+import { AutoReactionsPlugin } from "./plugins/AutoReactionsPlugin";
+import { PingableRolesPlugin } from "./plugins/PingableRolesPlugin";
+import { SelfGrantableRolesPlugin } from "./plugins/SelfGrantableRolesPlugin";
+import { RemindersPlugin } from "./plugins/Reminders";
 
 // Run latest database migrations
 logger.info("Running database migrations");
@@ -112,9 +113,10 @@ connect().then(async conn => {
       TagsPlugin,
       SlowmodePlugin,
       StarboardPlugin,
-      AutoReactions,
-      PingableRoles,
-      SelfGrantableRoles,
+      AutoReactionsPlugin,
+      PingableRolesPlugin,
+      SelfGrantableRolesPlugin,
+      RemindersPlugin,
     ],
 
     globalPlugins: [BotControlPlugin, LogServerPlugin],
diff --git a/src/migrations/1550609900261-CreateRemindersTable.ts b/src/migrations/1550609900261-CreateRemindersTable.ts
new file mode 100644
index 00000000..e1e10fa2
--- /dev/null
+++ b/src/migrations/1550609900261-CreateRemindersTable.ts
@@ -0,0 +1,53 @@
+import { MigrationInterface, QueryRunner, Table } from "typeorm";
+
+export class CreateRemindersTable1550609900261 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<any> {
+    await queryRunner.createTable(
+      new Table({
+        name: "reminders",
+        columns: [
+          {
+            name: "id",
+            type: "int",
+            unsigned: true,
+            isGenerated: true,
+            generationStrategy: "increment",
+            isPrimary: true,
+          },
+          {
+            name: "guild_id",
+            type: "bigint",
+            unsigned: true,
+          },
+          {
+            name: "user_id",
+            type: "bigint",
+            unsigned: true,
+          },
+          {
+            name: "channel_id",
+            type: "bigint",
+            unsigned: true,
+          },
+          {
+            name: "remind_at",
+            type: "datetime",
+          },
+          {
+            name: "body",
+            type: "text",
+          },
+        ],
+        indices: [
+          {
+            columnNames: ["guild_id", "user_id"],
+          },
+        ],
+      }),
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<any> {
+    await queryRunner.dropTable("reminders", true);
+  }
+}
diff --git a/src/plugins/AutoReactions.ts b/src/plugins/AutoReactionsPlugin.ts
similarity index 98%
rename from src/plugins/AutoReactions.ts
rename to src/plugins/AutoReactionsPlugin.ts
index 81315bf1..fcf6e1ac 100644
--- a/src/plugins/AutoReactions.ts
+++ b/src/plugins/AutoReactionsPlugin.ts
@@ -6,7 +6,7 @@ import { Message } from "eris";
 import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils";
 import { ZeppelinPlugin } from "./ZeppelinPlugin";
 
-export class AutoReactions extends ZeppelinPlugin {
+export class AutoReactionsPlugin extends ZeppelinPlugin {
   public static pluginName = "auto_reactions";
 
   protected savedMessages: GuildSavedMessages;
diff --git a/src/plugins/CustomNameColors.ts b/src/plugins/CustomNameColors.ts
new file mode 100644
index 00000000..64604239
--- /dev/null
+++ b/src/plugins/CustomNameColors.ts
@@ -0,0 +1,107 @@
+import { Plugin, decorators as d } from "knub";
+import { GuildSavedMessages } from "../data/GuildSavedMessages";
+import { SavedMessage } from "../data/entities/SavedMessage";
+import { GuildAutoReactions } from "../data/GuildAutoReactions";
+import { Message } from "eris";
+import { CustomEmoji, customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils";
+
+export class AutoReactions extends Plugin {
+  public static pluginName = "auto_reactions";
+
+  protected savedMessages: GuildSavedMessages;
+  protected autoReactions: GuildAutoReactions;
+
+  private onMessageCreateFn;
+
+  getDefaultOptions() {
+    return {
+      permissions: {
+        use: false,
+      },
+
+      overrides: [
+        {
+          level: ">=100",
+          permissions: {
+            use: true,
+          },
+        },
+      ],
+    };
+  }
+
+  onLoad() {
+    this.savedMessages = GuildSavedMessages.getInstance(this.guildId);
+    this.autoReactions = GuildAutoReactions.getInstance(this.guildId);
+
+    this.onMessageCreateFn = this.savedMessages.events.on("create", this.onMessageCreate.bind(this));
+  }
+
+  onUnload() {
+    this.savedMessages.events.off("create", this.onMessageCreateFn);
+  }
+
+  @d.command("auto_reactions", "<channelId:channelId> <reactions...>")
+  @d.permission("use")
+  async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) {
+    const guildEmojis = this.guild.emojis as CustomEmoji[];
+    const guildEmojiIds = guildEmojis.map(e => e.id);
+
+    const finalReactions = [];
+
+    for (const reaction of args.reactions) {
+      if (!isEmoji(reaction)) {
+        console.log("invalid:", reaction);
+        msg.channel.createMessage(errorMessage("One or more of the specified reactions were invalid!"));
+        return;
+      }
+
+      let savedValue;
+
+      const customEmojiMatch = reaction.match(customEmojiRegex);
+      if (customEmojiMatch) {
+        // Custom emoji
+        if (!guildEmojiIds.includes(customEmojiMatch[2])) {
+          msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from this server"));
+
+          return;
+        }
+
+        savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`;
+      } else {
+        // Unicode emoji
+        savedValue = reaction;
+      }
+
+      finalReactions.push(savedValue);
+    }
+
+    await this.autoReactions.set(args.channelId, finalReactions);
+    msg.channel.createMessage(successMessage(`Auto-reactions set for <#${args.channelId}>`));
+  }
+
+  @d.command("auto_reactions disable", "<channelId:channelId>")
+  @d.permission("use")
+  async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) {
+    const autoReaction = await this.autoReactions.getForChannel(args.channelId);
+    if (!autoReaction) {
+      msg.channel.createMessage(errorMessage(`Auto-reactions aren't enabled in <#${args.channelId}>`));
+      return;
+    }
+
+    await this.autoReactions.removeFromChannel(args.channelId);
+    msg.channel.createMessage(successMessage(`Auto-reactions disabled in <#${args.channelId}>`));
+  }
+
+  async onMessageCreate(msg: SavedMessage) {
+    const autoReaction = await this.autoReactions.getForChannel(msg.channel_id);
+    if (!autoReaction) return;
+
+    const realMsg = await this.bot.getMessage(msg.channel_id, msg.id);
+    if (!realMsg) return;
+
+    for (const reaction of autoReaction.reactions) {
+      realMsg.addReaction(reaction);
+    }
+  }
+}
diff --git a/src/plugins/PingableRoles.ts b/src/plugins/PingableRolesPlugin.ts
similarity index 97%
rename from src/plugins/PingableRoles.ts
rename to src/plugins/PingableRolesPlugin.ts
index e1b8bc19..ba823469 100644
--- a/src/plugins/PingableRoles.ts
+++ b/src/plugins/PingableRolesPlugin.ts
@@ -1,5 +1,4 @@
 import { Plugin, decorators as d } from "knub";
-import { SavedMessage } from "../data/entities/SavedMessage";
 import { Message, Role, TextableChannel, User } from "eris";
 import { GuildPingableRoles } from "../data/GuildPingableRoles";
 import { PingableRole } from "../data/entities/PingableRole";
@@ -7,7 +6,7 @@ import { errorMessage, successMessage } from "../utils";
 
 const TIMEOUT = 10 * 1000;
 
-export class PingableRoles extends Plugin {
+export class PingableRolesPlugin extends Plugin {
   public static pluginName = "pingable_roles";
 
   protected pingableRoles: GuildPingableRoles;
diff --git a/src/plugins/Reminders.ts b/src/plugins/Reminders.ts
new file mode 100644
index 00000000..005cbc83
--- /dev/null
+++ b/src/plugins/Reminders.ts
@@ -0,0 +1,149 @@
+import { decorators as d } from "knub";
+import { ZeppelinPlugin } from "./ZeppelinPlugin";
+import { GuildReminders } from "../data/GuildReminders";
+import { Message, TextChannel } from "eris";
+import moment from "moment-timezone";
+import humanizeDuration from "humanize-duration";
+import { convertDelayStringToMS, createChunkedMessage, errorMessage, sorter, successMessage } from "../utils";
+
+const REMINDER_LOOP_TIME = 10 * 1000;
+const MAX_TRIES = 3;
+
+export class RemindersPlugin extends ZeppelinPlugin {
+  public static pluginName = "reminders";
+
+  protected reminders: GuildReminders;
+  protected tries: Map<number, number>;
+
+  private postRemindersTimeout;
+
+  getDefaultOptions() {
+    return {
+      permissions: {
+        use: false,
+      },
+
+      overrides: [
+        {
+          level: ">=50",
+          permissions: {
+            use: true,
+          },
+        },
+      ],
+    };
+  }
+
+  onLoad() {
+    this.reminders = GuildReminders.getInstance(this.guildId);
+    this.tries = new Map();
+    this.postDueRemindersLoop();
+  }
+
+  async postDueRemindersLoop() {
+    const pendingReminders = await this.reminders.getDueReminders();
+    for (const reminder of pendingReminders) {
+      const channel = this.guild.channels.get(reminder.channel_id);
+      if (channel && channel instanceof TextChannel) {
+        try {
+          await channel.createMessage(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`);
+        } catch (e) {
+          // Probably random Discord internal server error or missing permissions or somesuch
+          // Try again next round unless we've already tried to post this a bunch of times
+          const tries = this.tries.get(reminder.id) || 0;
+          if (tries < MAX_TRIES) {
+            this.tries.set(reminder.id, tries + 1);
+            continue;
+          }
+        }
+      }
+
+      await this.reminders.delete(reminder.id);
+    }
+
+    this.postRemindersTimeout = setTimeout(() => this.postDueRemindersLoop(), REMINDER_LOOP_TIME);
+  }
+
+  @d.command("remind", "<time:string> <reminder:string$>")
+  @d.command("remindme", "<time:string> <reminder:string$>")
+  @d.permission("use")
+  async addReminderCmd(msg: Message, args: { time: string; reminder: string }) {
+    const now = moment();
+
+    let reminderTime;
+    if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) {
+      // Date in YYYY-MM-DD format, remind at current time on that date
+      reminderTime = moment(args.time, "YYYY-M-D").set({
+        hour: now.hour(),
+        minute: now.minute(),
+        second: now.second(),
+      });
+    } else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) {
+      // Date and time in YYYY-MM-DD[T]HH:mm format
+      reminderTime = moment(args.time, "YYYY-M-D[T]HH:mm").second(0);
+    } else {
+      // "Delay string" i.e. e.g. "2h30m"
+      const ms = convertDelayStringToMS(args.time);
+      if (ms === null) {
+        msg.channel.createMessage(errorMessage("Invalid reminder time"));
+        return;
+      }
+
+      reminderTime = moment().add(ms, "millisecond");
+    }
+
+    if (!reminderTime.isValid() || reminderTime.isBefore(now)) {
+      msg.channel.createMessage(errorMessage("Invalid reminder time"));
+      return;
+    }
+
+    await this.reminders.add(msg.author.id, msg.channel.id, reminderTime.format("YYYY-MM-DD HH:mm:ss"), args.reminder);
+
+    const msUntilReminder = reminderTime.diff(now);
+    const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
+    msg.channel.createMessage(
+      successMessage(
+        `I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`,
+      ),
+    );
+  }
+
+  @d.command("reminders")
+  @d.permission("use")
+  async reminderListCmd(msg: Message) {
+    const reminders = await this.reminders.getRemindersByUserId(msg.author.id);
+    if (reminders.length === 0) {
+      msg.channel.createMessage(errorMessage("No reminders"));
+      return;
+    }
+
+    reminders.sort(sorter("remind_at"));
+    const longestNum = (reminders.length + 1).toString().length;
+    const lines = Array.from(reminders.entries()).map(([i, reminder]) => {
+      const num = i + 1;
+      const paddedNum = num.toString().padStart(longestNum, " ");
+      return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${reminder.body}`;
+    });
+
+    createChunkedMessage(msg.channel, lines.join("\n"));
+  }
+
+  @d.command("reminders delete", "<num:number>")
+  @d.command("reminders d", "<num:number>")
+  @d.permission("use")
+  async deleteReminderCmd(msg: Message, args: { num: number }) {
+    const reminders = await this.reminders.getRemindersByUserId(msg.author.id);
+    reminders.sort(sorter("remind_at"));
+    const lastNum = reminders.length + 1;
+
+    if (args.num > lastNum || args.num < 0) {
+      msg.channel.createMessage(errorMessage("Unknown reminder"));
+      return;
+    }
+
+    const toDelete = reminders[args.num - 1];
+    await this.reminders.delete(toDelete.id);
+
+    msg.channel.createMessage(successMessage("Reminder deleted"));
+  }
+}
diff --git a/src/plugins/SelfGrantableRoles.ts b/src/plugins/SelfGrantableRolesPlugin.ts
similarity index 99%
rename from src/plugins/SelfGrantableRoles.ts
rename to src/plugins/SelfGrantableRolesPlugin.ts
index ecfea331..5dfc3436 100644
--- a/src/plugins/SelfGrantableRoles.ts
+++ b/src/plugins/SelfGrantableRolesPlugin.ts
@@ -3,7 +3,7 @@ import { GuildSelfGrantableRoles } from "../data/GuildSelfGrantableRoles";
 import { GuildChannel, Message, Role, TextChannel } from "eris";
 import { chunkArray, errorMessage, sorter, successMessage } from "../utils";
 
-export class SelfGrantableRoles extends Plugin {
+export class SelfGrantableRolesPlugin extends Plugin {
   public static pluginName = "self_grantable_roles";
 
   protected selfGrantableRoles: GuildSelfGrantableRoles;