diff --git a/core/src/main/java/google/registry/groups/GmailClient.java b/core/src/main/java/google/registry/groups/GmailClient.java
index 131750973..5a30bc5e2 100644
--- a/core/src/main/java/google/registry/groups/GmailClient.java
+++ b/core/src/main/java/google/registry/groups/GmailClient.java
@@ -16,17 +16,21 @@ package google.registry.groups;
import static com.google.common.collect.Iterables.toArray;
+import com.google.api.client.http.HttpResponseException;
import com.google.api.services.gmail.Gmail;
import com.google.api.services.gmail.model.Message;
+import com.google.common.collect.ImmutableSet;
import com.google.common.net.MediaType;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import google.registry.config.RegistryConfig.Config;
import google.registry.util.EmailMessage;
import google.registry.util.EmailMessage.Attachment;
+import google.registry.util.Retrier;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.Properties;
+import java.util.function.Predicate;
import javax.inject.Inject;
import javax.mail.Address;
import javax.mail.BodyPart;
@@ -38,23 +42,25 @@ import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
-import org.apache.commons.codec.binary.Base64;
/** Sends {@link EmailMessage EmailMessages} through Google Workspace using {@link Gmail}. */
public final class GmailClient {
private final Gmail gmail;
+ private final Retrier retrier;
private final InternetAddress outgoingEmailAddressWithUsername;
private final InternetAddress replyToEmailAddress;
@Inject
GmailClient(
Gmail gmail,
+ Retrier retrier,
@Config("gSuiteNewOutgoingEmailAddress") String gSuiteOutgoingEmailAddress,
@Config("gSuiteOutgoingEmailDisplayName") String gSuiteOutgoingEmailDisplayName,
@Config("replyToEmailAddress") InternetAddress replyToEmailAddress) {
this.gmail = gmail;
+ this.retrier = retrier;
this.replyToEmailAddress = replyToEmailAddress;
try {
this.outgoingEmailAddressWithUsername =
@@ -73,12 +79,11 @@ public final class GmailClient {
@CanIgnoreReturnValue
public Message sendEmail(EmailMessage emailMessage) {
Message message = toGmailMessage(toMimeMessage(emailMessage));
- try {
- // "me" is reserved word for the authorized user of the Gmail API.
- return gmail.users().messages().send("me", message).execute();
- } catch (IOException e) {
- throw new EmailException(e);
- }
+ // Unlike other Cloud APIs such as GCS and SecretManager, Gmail does not retry on errors.
+ return retrier.callWithRetry(
+ // "me" is reserved word for the authorized user of the Gmail API.
+ () -> this.gmail.users().messages().send("me", message).execute(),
+ RetriableGmailExceptionPredicate.INSTANCE);
}
static Message toGmailMessage(MimeMessage message) {
@@ -86,10 +91,7 @@ public final class GmailClient {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
message.writeTo(buffer);
byte[] rawMessageBytes = buffer.toByteArray();
- String encodedEmail = Base64.encodeBase64URLSafeString(rawMessageBytes);
- Message gmailMessage = new Message();
- gmailMessage.setRaw(encodedEmail);
- return gmailMessage;
+ return new Message().encodeRaw(rawMessageBytes);
} catch (MessagingException | IOException e) {
throw new EmailException(e);
}
@@ -135,4 +137,41 @@ public final class GmailClient {
super(cause);
}
}
+
+ /**
+ * Determines if a Gmail API exception may be retried.
+ *
+ *
See online doc
+ * for details.
+ */
+ static class RetriableGmailExceptionPredicate implements Predicate {
+
+ static RetriableGmailExceptionPredicate INSTANCE = new RetriableGmailExceptionPredicate();
+
+ private static final int USAGE_LIMIT_EXCEEDED = 403;
+ private static final int TOO_MANY_REQUESTS = 429;
+ private static final int BACKEND_ERROR = 500;
+
+ private static final ImmutableSet TRANSIENT_OVERAGE_REASONS =
+ ImmutableSet.of("userRateLimitExceeded", "rateLimitExceeded");
+
+ @Override
+ public boolean test(Throwable e) {
+ if (e instanceof HttpResponseException) {
+ return testHttpResponse((HttpResponseException) e);
+ }
+ return true;
+ }
+
+ private boolean testHttpResponse(HttpResponseException e) {
+ int statusCode = e.getStatusCode();
+ if (statusCode == TOO_MANY_REQUESTS || statusCode == BACKEND_ERROR) {
+ return true;
+ }
+ if (statusCode == USAGE_LIMIT_EXCEEDED) {
+ return TRANSIENT_OVERAGE_REASONS.contains(e.getStatusMessage());
+ }
+ return false;
+ }
+ }
}
diff --git a/core/src/test/java/google/registry/groups/GmailClientTest.java b/core/src/test/java/google/registry/groups/GmailClientTest.java
new file mode 100644
index 000000000..759ba1912
--- /dev/null
+++ b/core/src/test/java/google/registry/groups/GmailClientTest.java
@@ -0,0 +1,168 @@
+// Copyright 2023 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.groups;
+
+import static com.google.common.net.MediaType.CSV_UTF_8;
+import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.services.gmail.Gmail;
+import com.google.api.services.gmail.model.Message;
+import google.registry.groups.GmailClient.RetriableGmailExceptionPredicate;
+import google.registry.util.EmailMessage;
+import google.registry.util.EmailMessage.Attachment;
+import google.registry.util.Retrier;
+import google.registry.util.SystemSleeper;
+import java.io.OutputStream;
+import javax.mail.Message.RecipientType;
+import javax.mail.Part;
+import javax.mail.internet.InternetAddress;
+import javax.mail.internet.MimeMessage;
+import javax.mail.internet.MimeMultipart;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.stubbing.Answer;
+import org.testcontainers.shaded.com.google.common.collect.ImmutableList;
+
+/** Unit tests for {@link GmailClient}. */
+@ExtendWith(MockitoExtension.class)
+public class GmailClientTest {
+
+ @Mock private Gmail gmail;
+ @Mock private HttpResponseException httpResponseException;
+ private GmailClient gmailClient;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ gmailClient =
+ new GmailClient(
+ gmail,
+ new Retrier(new SystemSleeper(), 3),
+ "from@example.com",
+ "My sender",
+ new InternetAddress("replyTo@example.com"));
+ }
+
+ @Test
+ public void toMimeMessage_fullMessage() throws Exception {
+ InternetAddress fromAddr = new InternetAddress("from@example.com", "My sender");
+ InternetAddress toAddr = new InternetAddress("to@example.com");
+ InternetAddress ccAddr = new InternetAddress("cc@example.com");
+ InternetAddress bccAddr = new InternetAddress("bcc@example.com");
+ EmailMessage emailMessage =
+ EmailMessage.newBuilder()
+ .setFrom(fromAddr)
+ .setRecipients(ImmutableList.of(toAddr))
+ .setSubject("My subject")
+ .setBody("My body")
+ .addCc(ccAddr)
+ .addBcc(bccAddr)
+ .setAttachment(
+ Attachment.newBuilder()
+ .setFilename("filename")
+ .setContent("foo,bar\nbaz,qux")
+ .setContentType(CSV_UTF_8)
+ .build())
+ .build();
+ MimeMessage mimeMessage = gmailClient.toMimeMessage(emailMessage);
+ assertThat(mimeMessage.getFrom()).asList().containsExactly(fromAddr);
+ assertThat(mimeMessage.getRecipients(RecipientType.TO)).asList().containsExactly(toAddr);
+ assertThat(mimeMessage.getRecipients(RecipientType.CC)).asList().containsExactly(ccAddr);
+ assertThat(mimeMessage.getRecipients(RecipientType.BCC)).asList().containsExactly(bccAddr);
+ assertThat(mimeMessage.getSubject()).isEqualTo("My subject");
+ assertThat(mimeMessage.getContent()).isInstanceOf(MimeMultipart.class);
+ MimeMultipart parts = (MimeMultipart) mimeMessage.getContent();
+ Part body = parts.getBodyPart(0);
+ assertThat(body.getContentType()).isEqualTo(PLAIN_TEXT_UTF_8.toString());
+ assertThat(body.getContent()).isEqualTo("My body");
+ Part attachment = parts.getBodyPart(1);
+ assertThat(attachment.getContentType()).startsWith(CSV_UTF_8.toString());
+ assertThat(attachment.getContentType()).endsWith("name=filename");
+ assertThat(attachment.getContent()).isEqualTo("foo,bar\nbaz,qux");
+ }
+
+ @Test
+ public void toGmailMessage() throws Exception {
+ MimeMessage mimeMessage = mock(MimeMessage.class);
+ byte[] data = "My content".getBytes(UTF_8);
+ doAnswer(
+ new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) throws Throwable {
+ OutputStream os = invocation.getArgument(0);
+ os.write(data);
+ return null;
+ }
+ })
+ .when(mimeMessage)
+ .writeTo(any(OutputStream.class));
+ Message gmailMessage = GmailClient.toGmailMessage(mimeMessage);
+ assertThat(gmailMessage.decodeRaw()).isEqualTo(data);
+ }
+
+ @Test
+ public void isRetriable_trueIfNotHttpResponseException() {
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(new Exception())).isTrue();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_trueIf500() {
+ when(httpResponseException.getStatusCode()).thenReturn(500);
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isTrue();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_trueIf429() {
+ when(httpResponseException.getStatusCode()).thenReturn(429);
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isTrue();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_trueIf403WithRateLimitOverage() {
+ when(httpResponseException.getStatusCode()).thenReturn(403);
+ when(httpResponseException.getStatusMessage()).thenReturn("rateLimitExceeded");
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isTrue();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_trueIf403WithUserRateLimitOverage() {
+ when(httpResponseException.getStatusCode()).thenReturn(403);
+ when(httpResponseException.getStatusMessage()).thenReturn("userRateLimitExceeded");
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isTrue();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_falseIf403WithLongLastingOverage() {
+ when(httpResponseException.getStatusCode()).thenReturn(403);
+ when(httpResponseException.getStatusMessage()).thenReturn("dailyLimitExceeded");
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isFalse();
+ }
+
+ @Test
+ public void isHttpResponseExceptionRetriable_falseIfBadRequest() {
+ when(httpResponseException.getStatusCode()).thenReturn(400);
+ assertThat(RetriableGmailExceptionPredicate.INSTANCE.test(httpResponseException)).isFalse();
+ }
+}