Send email for packages over create limit (#1835)

* Send email for packages over create limit

* Small change to query

* Fix small nits
This commit is contained in:
sarahcaseybot 2022-11-10 18:08:27 -05:00 committed by GitHub
parent 78ca14e426
commit cf0560607e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 213 additions and 26 deletions

View file

@ -13,17 +13,24 @@
// limitations under the License. // limitations under the License.
package google.registry.batch; package google.registry.batch;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm; import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import google.registry.model.domain.DomainHistory; import com.google.common.primitives.Ints;
import google.registry.config.RegistryConfig.Config;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.domain.token.PackagePromotion; import google.registry.model.domain.token.PackagePromotion;
import google.registry.model.registrar.Registrar;
import google.registry.model.registrar.RegistrarPoc;
import google.registry.request.Action; import google.registry.request.Action;
import google.registry.request.Action.Service; import google.registry.request.Action.Service;
import google.registry.request.auth.Auth; import google.registry.request.auth.Auth;
import java.util.List; import google.registry.ui.server.SendEmailUtils;
import java.util.Optional;
import javax.inject.Inject;
/** /**
* An action that checks all {@link PackagePromotion} objects for compliance with their max create * An action that checks all {@link PackagePromotion} objects for compliance with their max create
@ -37,6 +44,22 @@ public class CheckPackagesComplianceAction implements Runnable {
public static final String PATH = "/_dr/task/checkPackagesCompliance"; public static final String PATH = "/_dr/task/checkPackagesCompliance";
private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final SendEmailUtils sendEmailUtils;
private final String packageCreateLimitEmailSubjectText;
private final String packageCreateLimitEmailBodyText;
private final String registrySupportEmail;
@Inject
public CheckPackagesComplianceAction(
SendEmailUtils sendEmailUtils,
@Config("packageCreateLimitEmailSubjectText") String packageCreateLimitEmailSubjectText,
@Config("packageCreateLimitEmailBodyText") String packageCreateLimitEmailBodyText,
@Config("registrySupportEmail") String registrySupportEmail) {
this.sendEmailUtils = sendEmailUtils;
this.packageCreateLimitEmailSubjectText = packageCreateLimitEmailSubjectText;
this.packageCreateLimitEmailBodyText = packageCreateLimitEmailBodyText;
this.registrySupportEmail = registrySupportEmail;
}
@Override @Override
public void run() { public void run() {
@ -46,19 +69,19 @@ public class CheckPackagesComplianceAction implements Runnable {
ImmutableList.Builder<PackagePromotion> packagesOverCreateLimit = ImmutableList.Builder<PackagePromotion> packagesOverCreateLimit =
new ImmutableList.Builder<>(); new ImmutableList.Builder<>();
for (PackagePromotion packagePromo : packages) { for (PackagePromotion packagePromo : packages) {
List<DomainHistory> creates = Long creates =
(Long)
jpaTm() jpaTm()
.query( .query(
"FROM DomainHistory WHERE current_package_token = :token AND" "SELECT COUNT(*) FROM DomainHistory WHERE current_package_token ="
+ " modificationTime >= :lastBilling AND type = 'DOMAIN_CREATE'", + " :token AND modificationTime >= :lastBilling AND type ="
DomainHistory.class) + " 'DOMAIN_CREATE'")
.setParameter("token", packagePromo.getToken().getKey().toString()) .setParameter("token", packagePromo.getToken().getKey().toString())
.setParameter( .setParameter(
"lastBilling", packagePromo.getNextBillingDate().minusYears(1)) "lastBilling", packagePromo.getNextBillingDate().minusYears(1))
.getResultList(); .getSingleResult();
if (creates > packagePromo.getMaxCreates()) {
if (creates.size() > packagePromo.getMaxCreates()) { int overage = Ints.saturatedCast(creates) - packagePromo.getMaxCreates();
int overage = creates.size() - packagePromo.getMaxCreates();
logger.atInfo().log( logger.atInfo().log(
"Package with package token %s has exceeded their max domain creation limit" "Package with package token %s has exceeded their max domain creation limit"
+ " by %d name(s).", + " by %d name(s).",
@ -72,9 +95,43 @@ public class CheckPackagesComplianceAction implements Runnable {
logger.atInfo().log( logger.atInfo().log(
"Found %d packages over their create limit.", "Found %d packages over their create limit.",
packagesOverCreateLimit.build().size()); packagesOverCreateLimit.build().size());
// TODO(sarahbot@) Send email to registrar and registry informing of creation for (PackagePromotion packagePromotion : packagesOverCreateLimit.build()) {
// overage once email template is finalized. AllocationToken packageToken = tm().loadByKey(packagePromotion.getToken());
Optional<Registrar> registrar =
Registrar.loadByRegistrarIdCached(
packageToken.getAllowedRegistrarIds().iterator().next());
if (registrar.isPresent()) {
String body =
String.format(
packageCreateLimitEmailBodyText,
registrar.get().getRegistrarName(),
packageToken.getToken(),
registrySupportEmail);
sendNotification(
packageToken, packageCreateLimitEmailSubjectText, body, registrar.get());
} else {
logger.atSevere().log(
String.format(
"Could not find registrar for package token %s", packageToken));
}
}
} }
}); });
} }
private void sendNotification(
AllocationToken packageToken, String subject, String body, Registrar registrar) {
logger.atInfo().log(
String.format(
"Compliance email sent to the %s registrar regarding the package with token" + " %s.",
registrar.getRegistrarName(), packageToken.getToken()));
sendEmailUtils.sendEmail(
subject,
body,
Optional.of(registrySupportEmail),
registrar.getContacts().stream()
.filter(c -> c.getTypes().contains(RegistrarPoc.Type.ADMIN))
.map(RegistrarPoc::getEmailAddress)
.collect(toImmutableList()));
}
} }

View file

@ -1309,6 +1309,18 @@ public final class RegistryConfig {
public static int provideHibernateJdbcBatchSize(RegistryConfigSettings config) { public static int provideHibernateJdbcBatchSize(RegistryConfigSettings config) {
return config.hibernate.jdbcBatchSize; return config.hibernate.jdbcBatchSize;
} }
@Provides
@Config("packageCreateLimitEmailSubjectText")
public static String providePackageCreateLimitEmailSubjectText(RegistryConfigSettings config) {
return config.packageMonitoring.packageCreateLimitEmailSubjectText;
}
@Provides
@Config("packageCreateLimitEmailBodyText")
public static String providePackageCreateLimitEmailBodyText(RegistryConfigSettings config) {
return config.packageMonitoring.packageCreateLimitEmailBodyText;
}
} }
/** Returns the App Engine project ID, which is based off the environment name. */ /** Returns the App Engine project ID, which is based off the environment name. */

View file

@ -43,6 +43,7 @@ public class RegistryConfigSettings {
public SslCertificateValidation sslCertificateValidation; public SslCertificateValidation sslCertificateValidation;
public ContactHistory contactHistory; public ContactHistory contactHistory;
public DnsUpdate dnsUpdate; public DnsUpdate dnsUpdate;
public PackageMonitoring packageMonitoring;
/** Configuration options that apply to the entire App Engine project. */ /** Configuration options that apply to the entire App Engine project. */
public static class AppEngine { public static class AppEngine {
@ -256,4 +257,10 @@ public class RegistryConfigSettings {
public String registrySupportEmail; public String registrySupportEmail;
public String registryCcEmail; public String registryCcEmail;
} }
/** Configuration for package compliance monitoring. */
public static class PackageMonitoring {
public String packageCreateLimitEmailSubjectText;
public String packageCreateLimitEmailBodyText;
}
} }

View file

@ -535,3 +535,21 @@ sslCertificateValidation:
allowedEcdsaCurves: allowedEcdsaCurves:
- secp256r1 - secp256r1
- secp384r1 - secp384r1
# Configuration options for the package compliance monitoring
packageMonitoring:
# Email subject text to notify partners their package has exceeded the limit for domain creates
packageCreateLimitEmailSubjectText: "NOTICE: Your Package Is Being Upgraded"
# Email body text template notify partners their package has exceeded the limit for domain creates
packageCreateLimitEmailBodyText: >
Dear %1$s,
We are contacting you to inform you that your package with the package token
%2$s has exceeded its limit for annual domain creations.
Your package will now be upgraded to the next tier.
If you have any questions or require additional support, please contact us
at %3$s.
Regards,
Example Registry

View file

@ -23,6 +23,7 @@ import google.registry.config.RegistryConfig.Config;
import google.registry.util.EmailMessage; import google.registry.util.EmailMessage;
import google.registry.util.SendEmailService; import google.registry.util.SendEmailService;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.inject.Inject; import javax.inject.Inject;
import javax.mail.internet.AddressException; import javax.mail.internet.AddressException;
@ -54,8 +55,9 @@ public class SendEmailUtils {
} }
/** /**
* Sends an email from Nomulus to the registrarChangesNotificationEmailAddresses and the specified * Sends an email from Nomulus to the registrarChangesNotificationEmailAddresses, the bcc address,
* additionalAddresses. Returns true iff sending to at least 1 address was successful. * and the specified additionalAddresses. Returns true iff sending to at least 1 address was
* successful.
* *
* <p>This means that if there are no recipients ({@link #hasRecipients} returns false), this will * <p>This means that if there are no recipients ({@link #hasRecipients} returns false), this will
* return false even thought no error happened. * return false even thought no error happened.
@ -64,7 +66,10 @@ public class SendEmailUtils {
* not all) of the recipients had an error. * not all) of the recipients had an error.
*/ */
public boolean sendEmail( public boolean sendEmail(
final String subject, String body, ImmutableList<String> additionalAddresses) { final String subject,
String body,
Optional<String> bcc,
ImmutableList<String> additionalAddresses) {
try { try {
InternetAddress from = InternetAddress from =
new InternetAddress( new InternetAddress(
@ -89,13 +94,22 @@ public class SendEmailUtils {
if (recipients.isEmpty()) { if (recipients.isEmpty()) {
return false; return false;
} }
emailService.sendEmail( EmailMessage.Builder emailMessage =
EmailMessage.newBuilder() EmailMessage.newBuilder()
.setBody(body) .setBody(body)
.setSubject(subject) .setSubject(subject)
.setRecipients(recipients) .setRecipients(recipients)
.setFrom(from) .setFrom(from);
.build()); if (bcc.isPresent()) {
try {
InternetAddress bccInternetAddress = new InternetAddress(bcc.get(), true);
emailMessage.addBcc(bccInternetAddress);
} catch (AddressException e) {
logger.atSevere().withCause(e).log(
"Could not send email to %s with subject '%s'.", bcc, subject);
}
}
emailService.sendEmail(emailMessage.build());
return true; return true;
} catch (Throwable t) { } catch (Throwable t) {
logger.atSevere().withCause(t).log( logger.atSevere().withCause(t).log(
@ -105,6 +119,21 @@ public class SendEmailUtils {
} }
} }
/**
* Sends an email from Nomulus to the registrarChangesNotificationEmailAddresses and the specified
* additionalAddresses. Returns true iff sending to at least 1 address was successful.
*
* <p>This means that if there are no recipients ({@link #hasRecipients} returns false), this will
* return false even thought no error happened.
*
* <p>This also means that if there are multiple recipients, it will return true even if some (but
* not all) of the recipients had an error.
*/
public boolean sendEmail(
final String subject, String body, ImmutableList<String> additionalAddresses) {
return sendEmail(subject, body, Optional.empty(), additionalAddresses);
}
/** /**
* Sends an email from Nomulus to the registrarChangesNotificationEmailAddresses. * Sends an email from Nomulus to the registrarChangesNotificationEmailAddresses.
* *

View file

@ -13,13 +13,20 @@
// limitations under the License. // limitations under the License.
package google.registry.batch; package google.registry.batch;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
import static google.registry.testing.DatabaseHelper.createTld; import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.persistActiveContact; import static google.registry.testing.DatabaseHelper.persistActiveContact;
import static google.registry.testing.DatabaseHelper.persistEppResource; import static google.registry.testing.DatabaseHelper.persistEppResource;
import static google.registry.testing.DatabaseHelper.persistResource; import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.LogsSubject.assertAboutLogs; import static google.registry.testing.LogsSubject.assertAboutLogs;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import com.google.common.collect.ImmutableList;
import com.google.common.testing.TestLogHandler; import com.google.common.testing.TestLogHandler;
import google.registry.model.billing.BillingEvent.RenewalPriceBehavior; import google.registry.model.billing.BillingEvent.RenewalPriceBehavior;
import google.registry.model.contact.Contact; import google.registry.model.contact.Contact;
@ -29,8 +36,12 @@ import google.registry.model.domain.token.PackagePromotion;
import google.registry.testing.AppEngineExtension; import google.registry.testing.AppEngineExtension;
import google.registry.testing.DatabaseHelper; import google.registry.testing.DatabaseHelper;
import google.registry.testing.FakeClock; import google.registry.testing.FakeClock;
import google.registry.ui.server.SendEmailUtils;
import google.registry.util.EmailMessage;
import google.registry.util.SendEmailService;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import javax.mail.internet.InternetAddress;
import org.joda.money.CurrencyUnit; import org.joda.money.CurrencyUnit;
import org.joda.money.Money; import org.joda.money.Money;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@ -38,12 +49,16 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.ArgumentCaptor;
import org.testcontainers.shaded.com.google.common.collect.ImmutableSet; import org.testcontainers.shaded.com.google.common.collect.ImmutableSet;
/** Unit tests for {@link CheckPackagesComplianceAction}. */ /** Unit tests for {@link CheckPackagesComplianceAction}. */
public class CheckPackagesComplianceActionTest { public class CheckPackagesComplianceActionTest {
// This is the default creation time for test data. // This is the default creation time for test data.
private final FakeClock clock = new FakeClock(DateTime.parse("2012-03-25TZ")); private final FakeClock clock = new FakeClock(DateTime.parse("2012-03-25TZ"));
private static final String CREATE_LIMIT_EMAIL_SUBJECT = "create limit subject";
private static final String CREATE_LIMIT_EMAIL_BODY = "create limit body %1$s %2$s %3$s";
private static final String SUPPORT_EMAIL = "registry@test.com";
@RegisterExtension @RegisterExtension
public final AppEngineExtension appEngine = public final AppEngineExtension appEngine =
@ -54,15 +69,26 @@ public class CheckPackagesComplianceActionTest {
private final TestLogHandler logHandler = new TestLogHandler(); private final TestLogHandler logHandler = new TestLogHandler();
private final Logger loggerToIntercept = private final Logger loggerToIntercept =
Logger.getLogger(CheckPackagesComplianceAction.class.getCanonicalName()); Logger.getLogger(CheckPackagesComplianceAction.class.getCanonicalName());
private final SendEmailService emailService = mock(SendEmailService.class);
private Contact contact; private Contact contact;
private PackagePromotion packagePromotion; private PackagePromotion packagePromotion;
private SendEmailUtils sendEmailUtils;
private ArgumentCaptor<EmailMessage> emailCaptor = ArgumentCaptor.forClass(EmailMessage.class);
@BeforeEach @BeforeEach
void beforeEach() { void beforeEach() throws Exception {
loggerToIntercept.addHandler(logHandler); loggerToIntercept.addHandler(logHandler);
sendEmailUtils =
new SendEmailUtils(
new InternetAddress("outgoing@registry.example"),
"UnitTest Registry",
ImmutableList.of("notification@test.example", "notification2@test.example"),
emailService);
createTld("tld"); createTld("tld");
action = new CheckPackagesComplianceAction(); action =
new CheckPackagesComplianceAction(
sendEmailUtils, CREATE_LIMIT_EMAIL_SUBJECT, CREATE_LIMIT_EMAIL_BODY, SUPPORT_EMAIL);
token = token =
persistResource( persistResource(
new AllocationToken.Builder() new AllocationToken.Builder()
@ -102,13 +128,14 @@ public class CheckPackagesComplianceActionTest {
.build()); .build());
action.run(); action.run();
verifyNoInteractions(emailService);
assertAboutLogs() assertAboutLogs()
.that(logHandler) .that(logHandler)
.hasLogAtLevelWithMessage(Level.INFO, "Found no packages over their create limit."); .hasLogAtLevelWithMessage(Level.INFO, "Found no packages over their create limit.");
} }
@Test @Test
void testSuccess_onePackageOverCreateLimit() { void testSuccess_onePackageOverCreateLimit() throws Exception {
// Create limit is 1, creating 2 domains to go over the limit // Create limit is 1, creating 2 domains to go over the limit
persistEppResource( persistEppResource(
DatabaseHelper.newDomain("foo.tld", contact) DatabaseHelper.newDomain("foo.tld", contact)
@ -131,6 +158,12 @@ public class CheckPackagesComplianceActionTest {
Level.INFO, Level.INFO,
"Package with package token abc123 has exceeded their max domain creation limit by 1" "Package with package token abc123 has exceeded their max domain creation limit by 1"
+ " name(s)."); + " name(s).");
verify(emailService).sendEmail(emailCaptor.capture());
EmailMessage emailMessage = emailCaptor.getValue();
assertThat(emailMessage.subject()).isEqualTo(CREATE_LIMIT_EMAIL_SUBJECT);
assertThat(emailMessage.body())
.isEqualTo(
String.format(CREATE_LIMIT_EMAIL_BODY, "The Registrar", "abc123", SUPPORT_EMAIL));
} }
@Test @Test
@ -196,6 +229,7 @@ public class CheckPackagesComplianceActionTest {
Level.INFO, Level.INFO,
"Package with package token token has exceeded their max domain creation limit by 1" "Package with package token token has exceeded their max domain creation limit by 1"
+ " name(s)."); + " name(s).");
verify(emailService, times(2)).sendEmail(any(EmailMessage.class));
} }
@Test @Test
@ -237,5 +271,6 @@ public class CheckPackagesComplianceActionTest {
assertAboutLogs() assertAboutLogs()
.that(logHandler) .that(logHandler)
.hasLogAtLevelWithMessage(Level.INFO, "Found no packages over their create limit."); .hasLogAtLevelWithMessage(Level.INFO, "Found no packages over their create limit.");
verifyNoInteractions(emailService);
} }
} }

View file

@ -24,6 +24,7 @@ import static org.mockito.Mockito.verify;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import google.registry.util.EmailMessage; import google.registry.util.EmailMessage;
import google.registry.util.SendEmailService; import google.registry.util.SendEmailService;
import java.util.Optional;
import javax.mail.MessagingException; import javax.mail.MessagingException;
import javax.mail.internet.InternetAddress; import javax.mail.internet.InternetAddress;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -119,6 +120,34 @@ class SendEmailUtilsTest {
verifyMessageSent("foo@example.com"); verifyMessageSent("foo@example.com");
} }
@Test
void testSuccess_bcc() throws Exception {
setRecipients(ImmutableList.of("johnny@fakesite.tld"));
assertThat(sendEmailUtils.hasRecipients()).isTrue();
sendEmailUtils.sendEmail(
"Welcome to the Internet",
"It is a dark and scary place.",
Optional.of("bar@example.com"),
ImmutableList.of("baz@example.com"));
ArgumentCaptor<EmailMessage> contentCaptor = ArgumentCaptor.forClass(EmailMessage.class);
verify(emailService).sendEmail(contentCaptor.capture());
EmailMessage emailMessage = contentCaptor.getValue();
ImmutableList.Builder<InternetAddress> recipientBuilder = ImmutableList.builder();
for (String expectedRecipient : ImmutableList.of("johnny@fakesite.tld", "baz@example.com")) {
recipientBuilder.add(new InternetAddress(expectedRecipient));
}
EmailMessage expectedContent =
EmailMessage.newBuilder()
.setSubject("Welcome to the Internet")
.setBody("It is a dark and scary place.")
.setFrom(new InternetAddress("outgoing@registry.example"))
.addBcc(new InternetAddress("bar@example.com"))
.setRecipients(recipientBuilder.build())
.build();
assertThat(emailMessage).isEqualTo(expectedContent);
}
@Test @Test
void testAdditionalRecipients() throws Exception { void testAdditionalRecipients() throws Exception {
setRecipients(ImmutableList.of("foo@example.com")); setRecipients(ImmutableList.of("foo@example.com"));