Implement email notification functionality

Add email notification functionality to rate-limiter plugin.
Notify user by email when soft or hard limit is reached.

Feature: Issue 10305
Change-Id: I9260291c6c4f77c184435fbe5b96ba0dfa5af72f
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java
index 810e85e..1e1f7d7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.ratelimiter;
 
 import com.google.common.collect.ArrayTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.AccountGroup;
@@ -27,8 +28,11 @@
 import com.google.inject.Singleton;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -40,6 +44,7 @@
       "Exceeded rate limit of " + RATE_LIMIT_TOKEN + " fetch requests/hour";
 
   private Table<RateLimitType, AccountGroup.UUID, RateLimit> rateLimits;
+  private List<AccountGroup.UUID> recipients;
   private final String rateLimitExceededMsg;
 
   @Inject
@@ -50,6 +55,31 @@
     Config config = pluginConfigFactory.getGlobalPluginConfig(pluginName);
     parseAllGroupsRateLimits(config, groupsCollection);
     rateLimitExceededMsg = parseLimitExceededMsg(config);
+    recipients = parseUserGroupsForEmailNotification(config, groupsCollection);
+  }
+
+  private List<AccountGroup.UUID> parseUserGroupsForEmailNotification(
+      Config config, GroupResolver groupsCollection) {
+    String sendEmailSection = "sendemail";
+    String recipients = "recipients";
+    Optional<String> rowValueOptional =
+        Optional.ofNullable(config.getString(sendEmailSection, null, recipients));
+    return rowValueOptional
+        .map(s -> resolveGroupsFromParsedValue(s, groupsCollection))
+        .orElseGet(ImmutableList::of);
+  }
+
+  private List<AccountGroup.UUID> resolveGroupsFromParsedValue(
+      String configValue, GroupResolver groupsCollection) {
+    List<AccountGroup.UUID> groups = new CopyOnWriteArrayList<>();
+    String[] groupNames = configValue.split("\\s*,\\s*");
+    for (String groupName : groupNames) {
+      Basic basic = groupsCollection.parseId(groupName);
+      if (basic != null) {
+        groups.add(basic.getGroupUUID());
+      }
+    }
+    return groups;
   }
 
   private void parseAllGroupsRateLimits(Config config, GroupResolver groupsCollection) {
@@ -116,4 +146,8 @@
   Map<AccountGroup.UUID, RateLimit> getRatelimits(RateLimitType rateLimitType) {
     return rateLimits != null ? rateLimits.row(rateLimitType) : ImmutableMap.of();
   }
+
+  List<AccountGroup.UUID> getRecipients() {
+    return !recipients.isEmpty() ? recipients : ImmutableList.of();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java
index a856772..5597723 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java
@@ -54,6 +54,7 @@
     install(new FactoryModuleBuilder().build(PeriodicRateLimiter.Factory.class));
     install(new FactoryModuleBuilder().build(WarningRateLimiter.Factory.class));
     install(new FactoryModuleBuilder().build(WarningUnlimitedRateLimiter.Factory.class));
+    install(new FactoryModuleBuilder().build(RateLimitReachedSender.Factory.class));
   }
 
   @Provides
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitReachedSender.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitReachedSender.java
new file mode 100644
index 0000000..35b50e2
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitReachedSender.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.ratelimiter;
+
+import com.google.common.io.CharStreams;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.EmailArguments;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.ProvisionException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.Objects;
+
+public class RateLimitReachedSender extends OutgoingEmail {
+  public interface Factory {
+    RateLimitReachedSender create(IdentifiedUser user, String emailmessage, boolean acquirePermit);
+  }
+
+  private final IdentifiedUser user;
+  private final String emailMessage;
+  private final MessageIdGenerator messageIdGenerator;
+  private final Configuration configuration;
+  private final boolean acquirePermit;
+
+  @AssistedInject
+  public RateLimitReachedSender(
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      Configuration configuration,
+      @Assisted IdentifiedUser user,
+      @Assisted String emailMessage,
+      @Assisted boolean acquirePermit) {
+    super(args, "RateLimitReached");
+    this.messageIdGenerator = messageIdGenerator;
+    this.configuration = configuration;
+    this.acquirePermit = acquirePermit;
+    this.user = user;
+    this.emailMessage = emailMessage;
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setHeader("Subject", "[Gerrit Code Review] " + emailMessage);
+    setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "rate_limit_reached", user.getAccountId(), TimeUtil.now()));
+    add(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    return user.getEffectiveGroups().containsAnyOf(configuration.getRecipients());
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(soyUseTextTemplate("RateLimiterEmailFormat"));
+    if (useHtml()) {
+      appendHtml(soyUseHtmlTemplate("RateLimiterEmailFormatHTML"));
+    }
+  }
+
+  private String soyUseTextTemplate(String template) {
+    SoySauce.Renderer renderer = getRenderer(template);
+    return renderer.renderText().get();
+  }
+
+  private String soyUseHtmlTemplate(String template) {
+    SoySauce.Renderer renderer = getRenderer(template);
+    return renderer.renderHtml().get().toString();
+  }
+
+  private SoySauce.Renderer getRenderer(String template) {
+    SoyFileSet.Builder builder = SoyFileSet.builder();
+    String content;
+
+    try (Reader r =
+        new BufferedReader(
+            new InputStreamReader(
+                Objects.requireNonNull(
+                    this.getClass().getResourceAsStream("/" + template + ".soy"))))) {
+      content = CharStreams.toString(r);
+    } catch (IOException err) {
+      throw new ProvisionException(
+          "Failed to read template file " + "/resources/" + template + ".soy", err);
+    }
+    builder.add(content, "/" + template + ".soy");
+    String renderedTemplate =
+        new StringBuilder("com.googlesource.gerrit.plugins.ratelimiter.")
+            .append(template)
+            .append(".")
+            .append(template)
+            .toString();
+    SoySauce.Renderer renderer =
+        builder.build().compileTemplates().renderTemplate(renderedTemplate).setData(soyContext);
+    return renderer;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
+    soyContextEmailData.put("log", emailMessage);
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiter.java
index 317cd26..68cc5ee 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiter.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.ratelimiter;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.exceptions.NoSuchAccountException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.time.LocalTime;
@@ -33,6 +34,7 @@
 
   private final UserResolver userResolver;
   private final RateLimiter delegate;
+  private final RateLimitReachedSender.Factory rateLimitReachedSenderFactory;
   private final int warnLimit;
   private final String key;
   private final long timeLapse;
@@ -43,12 +45,14 @@
   @Inject
   WarningRateLimiter(
       UserResolver userResolver,
+      RateLimitReachedSender.Factory rateLimitReachedSenderFactory,
       @Assisted RateLimiter delegate,
       @Assisted String key,
       @Assisted int warnLimit,
       @Assisted long timeLapse) {
     this.userResolver = userResolver;
     this.delegate = delegate;
+    this.rateLimitReachedSenderFactory = rateLimitReachedSenderFactory;
     this.warnLimit = warnLimit;
     this.key = key;
     this.timeLapse = timeLapse;
@@ -63,29 +67,46 @@
   public synchronized boolean acquirePermit() {
     boolean acquirePermit = delegate.acquirePermit();
     if (usedPermits() == warnLimit) {
-      rateLimitLog.info(
-          "{} reached the warning limit of {} {} per {} minutes.",
-          userResolver.getUserName(key).orElse(key),
-          warnLimit,
-          delegate.getType(),
-          timeLapse);
+      String emailMessage =
+          String.format(
+              "User %s reached the warning limit of %s %s per %s minutes.",
+              userResolver.getUserName(key).orElse(key), warnLimit, delegate.getType(), timeLapse);
+      rateLimitLog.info(emailMessage);
       warningWasLogged = true;
+      sendEmail(key, emailMessage, acquirePermit);
     }
 
     if (!acquirePermit && !wasLogged) {
-      rateLimitLog.info(
-          "{} was blocked due to exceeding the limit of {} {} per {} minutes."
-              + " {} remaining to permits replenishing.",
-          userResolver.getUserName(key).orElse(key),
-          permitsPerHour(),
-          delegate.getType(),
-          timeLapse,
-          secondsToMsSs(remainingTime(TimeUnit.SECONDS)));
+      String emailMessage =
+          String.format(
+              "User %s was blocked due to exceeding the limit of %s %s per %s minutes. %s remaining to permits replenishing.",
+              userResolver.getUserName(key).orElse(key),
+              permitsPerHour(),
+              delegate.getType(),
+              timeLapse,
+              secondsToMsSs(remainingTime(TimeUnit.SECONDS)));
+      rateLimitLog.info(emailMessage);
       wasLogged = true;
+      sendEmail(key, emailMessage, acquirePermit);
     }
     return acquirePermit;
   }
 
+  protected void sendEmail(String key, String emailMessage, boolean acquirePermit) {
+    try {
+      RateLimitReachedSender sender =
+          rateLimitReachedSenderFactory.create(
+              userResolver
+                  .getIdentifiedUser(key)
+                  .orElseThrow(() -> new NoSuchAccountException("User not found")),
+              emailMessage,
+              acquirePermit);
+      sender.send();
+    } catch (Exception e) {
+      rateLimitLog.error("Error with exception while sending email: " + e);
+    }
+  }
+
   @Override
   public int availablePermits() {
     return delegate.availablePermits();
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index e62f6f8..7059697 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -3,8 +3,12 @@
 The @PLUGIN@ plugin supports the following rate limits:
 
 * `uploadpackperhour` requests per period which are executed when a client runs a fetch command.
+* `uploadpackperhourwarn` soft limit of requests per period when a client runs a fetch command.
 * `timelapseinminutes` defines a period in minutes for the rate limiter. This value supports a
   limit of 60.
 
+Plugin has a functionality to send notification by email when warn or hard limit
+is reached.
+
 Rate limits define the maximum request rate for users in a given group
 for a given request type.
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index ac07f24..12faa84 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -4,9 +4,13 @@
 Rate Limits
 -----------
 
-The defined rate limits are stored in a `rate-limiter.config` file in the
-`{review_site}/etc` directory. Rate limits are defined per user group and
-rate limit type.
+The defined rate limits and user groups for email notification are stored in a
+`rate-limiter.config` file in the `{review_site}/etc` directory. Rate limits are
+defined per user group and rate limit type.
+
+Notification user groups are defined by setting 'recipients' in 'sendemail'
+section. The 'recipients' property specifies user groups, which members will
+receive notification emails.
 
 Example:
 
@@ -24,6 +28,9 @@
 
   [group "gerrit-user"]
     uploadpackperhourwarn = 10
+
+  [sendemail]
+    recipients = Access_Group_1, Access_Group_2, Access_Group_3
 ```
 
 In this example, the plugin will apply the uploadpackperhour and
@@ -46,6 +53,10 @@
 Use group "Registered Users" to define the default rate limit for all logged-in
 users.
 
+The recipients property of sendemail section define that emails about reaching
+soft and hard limits will be sent to members of `Access_Group_1, Access_Group_2,
+Access_Group_3` access groups.
+
 A second, "soft" limit can be defined for every rate limit, to warn
 administrators about the users that can be affected by an upcoming lower limit.
 In this case, the "soft" limit has the same name as the original limit, but
@@ -69,9 +80,23 @@
 The upload limitation will be enforced, i.e., the operation will be blocked,
 only when the user reaches 100 uploads.
 
-If the warn limit is present in the configuration but no hard limit,
-then no limit will be enforced but a log entry will be written when
-the user reaches the warning limit.
+When "soft" or "hard" limit is reached, user gets a notification email, if user
+is a member of one of the groups, defined in 'rate-limiter.config' (unless it's
+not an Anonymous user).
+
+NOTE: If limit is reached for Anonymous user or user who does not have an email
+(e.g. functional user), the email won't be sent as user will have link to
+anonymous query only. There will be also log message generated in rate-limiter
+log file while sending email:
+
+```
+  Error with exception while sending email:
+  com.google.gerrit.exceptions.NoSuchAccountException: Not Found: User not found
+```
+
+If the warn limit is present in the configuration but no hard limit, then no
+limit will be enforced, but a log entry will be written when the user reaches
+the warning limit.
 
 Format of the rate limit entries in `rate-limiter.config`:
 
diff --git a/src/main/resources/Documentation/notification-email.md b/src/main/resources/Documentation/notification-email.md
new file mode 100644
index 0000000..41b59a1
--- /dev/null
+++ b/src/main/resources/Documentation/notification-email.md
@@ -0,0 +1,38 @@
+Notification emails
+=============
+
+When user reaches "soft" of "hard" limit of requests per period, plugin checks,
+if user is a member of one of the groups, defined in configuration. If yes,
+plugin sends notification email to user email address.
+
+Email example, when user reaches "soft" limit:
+
+```
+  [Gerrit Code Review] User "username" reached the warning limit of "uploadpackperhourwarn"
+  upload pack per "timelapseinminutes" minutes.
+  This email is to inform that a user with "email@address" has exceeded a rate limit.
+
+  Log: User "username" reached the warning limit of "uploadpackperhourwarn" upload pack per
+  "timelapseinminutes" minutes.
+
+  This is a send-only email address. Replies to this message will not be read or answered.
+```
+
+Email example, when user reaches "hard" limit:
+
+```
+  [Gerrit Code Review] User "username" was blocked due to exceeding the limit of "uploadpackperhour"
+  upload pack per "timelapseinminutes" minutes. XX min YY sec remaining to permits replenishing.
+  This email is to inform that a user with "email@address" has exceeded a rate limit.
+
+  Log: User "username" was blocked due to exceeding the limit of "uploadpackperhour"
+  upload pack per "timelapseinminutes" minutes. XX min YY sec remaining to permits replenishing.
+
+  This is a send-only email address. Replies to this message will not be read or answered.
+```
+
+Notification email module is encapsulated in
+com.googlesource.gerrit.plugins.ratelimiter.RateLimitReachedSender class. It
+uses RateLimiterEmailFormat.soy and RateLimiterEmailFormatHTML.soy templates.
+Templates are located in resources. It's designed to minimize user impact to
+email templates.
diff --git a/src/main/resources/RateLimiterEmailFormat.soy b/src/main/resources/RateLimiterEmailFormat.soy
new file mode 100644
index 0000000..f1dc484
--- /dev/null
+++ b/src/main/resources/RateLimiterEmailFormat.soy
@@ -0,0 +1,34 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.googlesource.gerrit.plugins.ratelimiter.RateLimiterEmailFormat}
+
+/**
+ * The .RateLimiterEmailFormat template will determine the contents of the email related to
+ * user who reached a rate limit.
+ */
+{template RateLimiterEmailFormat kind="text"}
+  {@param email: ?}
+  This is to inform that a user with email {$email.userNameEmail} has exceeded a rate limit.
+
+  {\n}
+  {\n}
+
+  Log: {$email.log}
+
+  This is a send-only email address. Replies to this message will not be read
+  or answered.
+{/template}
diff --git a/src/main/resources/RateLimiterEmailFormatHTML.soy b/src/main/resources/RateLimiterEmailFormatHTML.soy
new file mode 100644
index 0000000..673dc00
--- /dev/null
+++ b/src/main/resources/RateLimiterEmailFormatHTML.soy
@@ -0,0 +1,33 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.googlesource.gerrit.plugins.ratelimiter.RateLimiterEmailFormatHTML}
+
+{template RateLimiterEmailFormatHTML}
+  {@param email: ?}
+  <p>
+    This is to inform that a user with email {$email.userNameEmail} has exceeded a rate limit.
+  </p>
+
+  <p>
+    Log: {$email.log}
+  </p>
+
+  <p>
+    This is a send-only email address. Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiterTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiterTest.java
index 9b41949..dc19df0 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningRateLimiterTest.java
@@ -17,12 +17,18 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.googlesource.gerrit.plugins.ratelimiter.PeriodicRateLimiter.DEFAULT_TIME_LAPSE_IN_MINUTES;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.server.IdentifiedUser;
+import java.util.Optional;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import org.junit.Before;
@@ -33,75 +39,76 @@
 
   private static final int RATE = 1000;
   private static final int WARN_RATE = 900;
-  private WarningRateLimiter warningLimiter1;
-  private WarningRateLimiter warningLimiter2;
-  private ScheduledExecutorService scheduledExecutorMock1;
+  private IdentifiedUser identifiedUser = mock(IdentifiedUser.class);
+  private WarningRateLimiter warningLimiter;
+  private ScheduledExecutorService scheduledExecutorMock;
   private UserResolver userResolver = mock(UserResolver.class);
+  private RateLimitReachedSender.Factory rateLimitReachedSenderFactory =
+      mock(RateLimitReachedSender.Factory.class);
+  private RateLimitReachedSender sender = mock(RateLimitReachedSender.class);
 
   @Before
   public void setUp() {
-    scheduledExecutorMock1 = mock(ScheduledExecutorService.class);
+    scheduledExecutorMock = mock(ScheduledExecutorService.class);
 
-    ScheduledExecutorService scheduledExecutorMock2 = mock(ScheduledExecutorService.class);
-
-    PeriodicRateLimiter limiter1 =
+    PeriodicRateLimiter limiter =
         spy(
             new PeriodicRateLimiter(
-                scheduledExecutorMock1, RATE, DEFAULT_TIME_LAPSE_IN_MINUTES, "Any Type"));
-    doReturn(1L).when(limiter1).remainingTime(any(TimeUnit.class));
+                scheduledExecutorMock, RATE, DEFAULT_TIME_LAPSE_IN_MINUTES, "Any Type"));
+    doReturn(1L).when(limiter).remainingTime(any(TimeUnit.class));
 
-    PeriodicRateLimiter limiter2 =
-        spy(
-            new PeriodicRateLimiter(
-                scheduledExecutorMock2, RATE, DEFAULT_TIME_LAPSE_IN_MINUTES, "Any Type"));
-    doReturn(1L).when(limiter2).remainingTime(any(TimeUnit.class));
-
-    warningLimiter1 =
+    warningLimiter =
         new WarningRateLimiter(
-            userResolver, limiter1, "dummy", WARN_RATE, DEFAULT_TIME_LAPSE_IN_MINUTES);
-    warningLimiter2 =
-        new WarningRateLimiter(
-            userResolver, limiter2, "dummy2", WARN_RATE, DEFAULT_TIME_LAPSE_IN_MINUTES);
+            userResolver,
+            rateLimitReachedSenderFactory,
+            limiter,
+            "dummy",
+            WARN_RATE,
+            DEFAULT_TIME_LAPSE_IN_MINUTES);
   }
 
   @Test
   public void testGetRatePerHour() {
-    assertThat(warningLimiter1.permitsPerHour()).isEqualTo(RATE);
+    assertThat(warningLimiter.permitsPerHour()).isEqualTo(RATE);
   }
 
   @Test
   public void testAcquireAll() {
-    assertThat(warningLimiter1.availablePermits()).isEqualTo(RATE);
+    assertThat(warningLimiter.availablePermits()).isEqualTo(RATE);
 
     for (int permitNum = 1; permitNum <= RATE; permitNum++) {
-      checkGetPermitPasses(warningLimiter1, permitNum);
+      checkGetPermitPasses(warningLimiter, permitNum);
     }
-    checkGetPermitFails(warningLimiter1);
+    checkGetPermitFails(warningLimiter);
   }
 
   @Test
-  public void testAcquireWarning() {
-    assertThat(warningLimiter2.availablePermits()).isEqualTo(RATE);
+  public void testAcquireWarning() throws EmailException {
+    when(userResolver.getIdentifiedUser(any())).thenReturn(Optional.ofNullable(identifiedUser));
+    when(rateLimitReachedSenderFactory.create(any(), any(), anyBoolean())).thenReturn(sender);
+    assertThat(warningLimiter.availablePermits()).isEqualTo(RATE);
 
     for (int permitNum = 1; permitNum < WARN_RATE; permitNum++) {
-      checkGetPermitPasses(warningLimiter2, permitNum);
+      checkGetPermitPasses(warningLimiter, permitNum);
     }
     // Check that the warning has not yet been triggered
-    assertThat(warningLimiter2.getWarningFlagState()).isFalse();
+    assertThat(warningLimiter.getWarningFlagState()).isFalse();
 
     // Trigger the warning
-    assertThat(warningLimiter2.acquirePermit()).isTrue();
-    assertThat(warningLimiter2.getWarningFlagState()).isTrue();
+    assertThat(warningLimiter.acquirePermit()).isTrue();
+    verify(sender, times(1)).send();
+    assertThat(warningLimiter.getWarningFlagState()).isTrue();
 
     for (int permitNum = WARN_RATE + 1; permitNum <= RATE; permitNum++) {
-      checkGetPermitPasses(warningLimiter2, permitNum);
+      checkGetPermitPasses(warningLimiter, permitNum);
     }
-    checkGetPermitFails(warningLimiter2);
+    checkGetPermitFails(warningLimiter);
+    verify(sender, times(2)).send();
   }
 
   @Test
   public void testReplenishPermitsIsScheduled() {
-    verify(scheduledExecutorMock1)
+    verify(scheduledExecutorMock)
         .scheduleAtFixedRate(
             any(),
             eq(DEFAULT_TIME_LAPSE_IN_MINUTES),
@@ -112,20 +119,20 @@
   @Test
   public void testReplenishPermitsScheduledRunnableIsWorking() {
     ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
-    verify(scheduledExecutorMock1)
+    verify(scheduledExecutorMock)
         .scheduleAtFixedRate(
             runnableCaptor.capture(),
             eq(DEFAULT_TIME_LAPSE_IN_MINUTES),
             eq(DEFAULT_TIME_LAPSE_IN_MINUTES),
             eq(TimeUnit.MINUTES));
 
-    replenishPermits(warningLimiter1, runnableCaptor);
+    replenishPermits(warningLimiter, runnableCaptor);
     testAcquireAll();
 
     // Check the available permits are used up
-    assertThat(warningLimiter1.availablePermits()).isEqualTo(0);
+    assertThat(warningLimiter.availablePermits()).isEqualTo(0);
 
-    replenishPermits(warningLimiter1, runnableCaptor);
+    replenishPermits(warningLimiter, runnableCaptor);
   }
 
   private void checkGetPermitPasses(RateLimiter rateLimiter, int permitNum) {