diff --git a/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java index aa732d72d..c1fceb2e5 100644 --- a/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java +++ b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java @@ -14,12 +14,26 @@ package google.registry.batch; +import static com.google.common.collect.ImmutableList.toImmutableList; import static google.registry.request.Action.Method.POST; +import static google.registry.util.RegistrarUtils.normalizeRegistrarId; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Streams; import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import google.registry.groups.GmailClient; +import google.registry.groups.GroupsConnection; +import google.registry.model.registrar.Registrar; +import google.registry.model.registrar.RegistrarPoc; import google.registry.request.Action; import google.registry.request.auth.Auth; +import google.registry.util.EmailMessage; +import java.io.IOException; +import java.util.Set; import javax.inject.Inject; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; /** * Action that executes a canned script specified by the caller. @@ -42,19 +56,80 @@ import javax.inject.Inject; public class CannedScriptExecutionAction implements Runnable { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final GroupsConnection groupsConnection; + private final GmailClient gmailClient; + + private final InternetAddress senderAddress; + + private final InternetAddress recipientAddress; + + private final String gSuiteDomainName; + @Inject - CannedScriptExecutionAction() { - logger.atInfo().log("Received request to run scripts."); + CannedScriptExecutionAction( + GroupsConnection groupsConnection, + GmailClient gmailClient, + @Config("projectId") String projectId, + @Config("gSuiteDomainName") String gSuiteDomainName, + @Config("alertRecipientEmailAddress") InternetAddress recipientAddress) { + this.groupsConnection = groupsConnection; + this.gmailClient = gmailClient; + this.gSuiteDomainName = gSuiteDomainName; + try { + this.senderAddress = new InternetAddress(String.format("%s@%s", projectId, gSuiteDomainName)); + } catch (AddressException e) { + throw new RuntimeException(e); + } + this.recipientAddress = recipientAddress; + logger.atInfo().log("Sender:%s; Recipient: %s.", this.senderAddress, this.recipientAddress); } @Override public void run() { try { // Invoke canned scripts here. + checkGroupApi(); + EmailMessage message = createEmail(); + this.gmailClient.sendEmail(message); logger.atInfo().log("Finished running scripts."); } catch (Throwable t) { logger.atWarning().withCause(t).log("Error executing scripts."); throw new RuntimeException("Execution failed."); } } + + // Checks if Directory and GroupSettings still work after GWorkspace changes. + void checkGroupApi() { + ImmutableList registrars = + Streams.stream(Registrar.loadAllCached()) + .filter(registrar -> registrar.isLive() && registrar.getType() == Registrar.Type.REAL) + .collect(toImmutableList()); + logger.atInfo().log("Found %s registrars.", registrars.size()); + for (Registrar registrar : registrars) { + for (final RegistrarPoc.Type type : RegistrarPoc.Type.values()) { + String groupKey = + String.format( + "%s-%s-contacts@%s", + normalizeRegistrarId(registrar.getRegistrarId()), + type.getDisplayName(), + gSuiteDomainName); + try { + Set currentMembers = groupsConnection.getMembersOfGroup(groupKey); + logger.atInfo().log("%s has %s members.", groupKey, currentMembers.size()); + } catch (IOException e) { + logger.atWarning().withCause(e).log("Failed to check %s", groupKey); + } + } + } + logger.atInfo().log("Finished checking GroupApis."); + } + + EmailMessage createEmail() { + return EmailMessage.newBuilder() + .setFrom(senderAddress) + .setSubject("Test: Please ignore.") + .setRecipients(ImmutableList.of(recipientAddress)) + .setBody("Sent from Nomulus through Google Workspace.") + .build(); + } } diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index ae6b38955..f41f4fe30 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -334,6 +334,8 @@ credentialOAuth: - https://www.googleapis.com/auth/admin.directory.group # View and manage group settings in Group Settings API. - https://www.googleapis.com/auth/apps.groups.settings + # Send email through Gmail. + - https://www.googleapis.com/auth/gmail.send # OAuth scopes required to create a credential locally in for the nomulus tool. localCredentialOauthScopes: # View and manage data in all Google Cloud APIs. diff --git a/core/src/main/java/google/registry/groups/GmailClient.java b/core/src/main/java/google/registry/groups/GmailClient.java new file mode 100644 index 000000000..aca725bfe --- /dev/null +++ b/core/src/main/java/google/registry/groups/GmailClient.java @@ -0,0 +1,120 @@ +// 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.collect.Iterables.toArray; + +import com.google.api.services.gmail.Gmail; +import com.google.api.services.gmail.model.Message; +import com.google.common.net.MediaType; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import google.registry.util.EmailMessage; +import google.registry.util.EmailMessage.Attachment; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Properties; +import javax.inject.Inject; +import javax.mail.Address; +import javax.mail.BodyPart; +import javax.mail.Message.RecipientType; +import javax.mail.MessagingException; +import javax.mail.Multipart; +import javax.mail.Session; +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; + + @Inject + GmailClient(Gmail gmail) { + this.gmail = gmail; + } + + /** + * Sends {@code emailMessage} using {@link Gmail}. + * + *

If the sender as specified by {@link EmailMessage#from} differs from the caller's identity, + * the caller must have delegated `send` authority to the sender. + */ + @CanIgnoreReturnValue + public Message sendEmail(EmailMessage emailMessage) { + Message message = toGmailMessage(toMimeMessage(emailMessage)); + try { + return gmail.users().messages().send("me", message).execute(); + } catch (IOException e) { + throw new EmailException(e); + } + } + + static Message toGmailMessage(MimeMessage message) { + try { + 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; + } catch (MessagingException | IOException e) { + throw new EmailException(e); + } + } + + static MimeMessage toMimeMessage(EmailMessage emailMessage) { + try { + MimeMessage msg = + new MimeMessage(Session.getDefaultInstance(new Properties(), /* authenticator= */ null)); + msg.setFrom(emailMessage.from()); + msg.addRecipients( + RecipientType.TO, toArray(emailMessage.recipients(), InternetAddress.class)); + msg.setSubject(emailMessage.subject()); + + Multipart multipart = new MimeMultipart(); + BodyPart bodyPart = new MimeBodyPart(); + bodyPart.setContent( + emailMessage.body(), + emailMessage.contentType().orElse(MediaType.PLAIN_TEXT_UTF_8).toString()); + multipart.addBodyPart(bodyPart); + + if (emailMessage.attachment().isPresent()) { + Attachment attachment = emailMessage.attachment().get(); + BodyPart attachmentPart = new MimeBodyPart(); + attachmentPart.setContent(attachment.content(), attachment.contentType().toString()); + attachmentPart.setFileName(attachment.filename()); + multipart.addBodyPart(attachmentPart); + } + msg.addRecipients(RecipientType.BCC, toArray(emailMessage.bccs(), Address.class)); + msg.addRecipients(RecipientType.CC, toArray(emailMessage.ccs(), Address.class)); + msg.setContent(multipart); + msg.saveChanges(); + return msg; + } catch (MessagingException e) { + throw new EmailException(e); + } + } + + static class EmailException extends RuntimeException { + + public EmailException(Throwable cause) { + super(cause); + } + } +} diff --git a/core/src/main/java/google/registry/groups/GmailModule.java b/core/src/main/java/google/registry/groups/GmailModule.java new file mode 100644 index 000000000..28f4a3f8e --- /dev/null +++ b/core/src/main/java/google/registry/groups/GmailModule.java @@ -0,0 +1,41 @@ +// 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 com.google.api.services.gmail.Gmail; +import dagger.Module; +import dagger.Provides; +import google.registry.config.CredentialModule.AdcDelegatedCredential; +import google.registry.config.RegistryConfig.Config; +import google.registry.util.GoogleCredentialsBundle; +import javax.inject.Singleton; + +/** Dagger module providing {@link Gmail} API. */ +@Module +public class GmailModule { + + @Provides + @Singleton + Gmail provideGmail( + @AdcDelegatedCredential GoogleCredentialsBundle credentialsBundle, + @Config("projectId") String projectId) { + return new Gmail.Builder( + credentialsBundle.getHttpTransport(), + credentialsBundle.getJsonFactory(), + credentialsBundle.getHttpRequestInitializer()) + .setApplicationName(projectId) + .build(); + } +} diff --git a/core/src/main/java/google/registry/module/backend/BackendComponent.java b/core/src/main/java/google/registry/module/backend/BackendComponent.java index dc7693616..b17493243 100644 --- a/core/src/main/java/google/registry/module/backend/BackendComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendComponent.java @@ -28,6 +28,7 @@ import google.registry.export.sheet.SheetsServiceModule; import google.registry.flows.ServerTridProviderModule; import google.registry.flows.custom.CustomLogicFactoryModule; import google.registry.groups.DirectoryModule; +import google.registry.groups.GmailModule; import google.registry.groups.GroupsModule; import google.registry.groups.GroupssettingsModule; import google.registry.keyring.KeyringModule; @@ -64,6 +65,7 @@ import javax.inject.Singleton; DirectoryModule.class, DummyKeyringModule.class, DriveModule.class, + GmailModule.class, GroupsModule.class, GroupssettingsModule.class, JSchModule.class,