Send email notifications to owners on serviceuser owners
Change-Id: I4ce32ef263a882f01d9af6803d90d62d1d35ed3c
diff --git a/BUILD b/BUILD
index 7aaa331..681d904 100644
--- a/BUILD
+++ b/BUILD
@@ -9,6 +9,7 @@
"Gerrit-Module: com.googlesource.gerrit.plugins.serviceuser.Module",
"Gerrit-HttpModule: com.googlesource.gerrit.plugins.serviceuser.HttpModule",
"Gerrit-SshModule: com.googlesource.gerrit.plugins.serviceuser.SshModule",
+ "Gerrit-InitStep: com.googlesource.gerrit.plugins.serviceuser.Init",
],
resource_jars = ["//plugins/serviceuser/web:serviceuser"],
resources = glob(["src/main/resources/**/*"]),
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AddSshKey.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AddSshKey.java
index c5ccadd..ed5c4ba 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AddSshKey.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AddSshKey.java
@@ -23,21 +23,31 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
class AddSshKey implements RestModifyView<ServiceUserResource, SshKeyInput> {
private final Provider<com.google.gerrit.server.restapi.account.AddSshKey> addSshKey;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
- AddSshKey(Provider<com.google.gerrit.server.restapi.account.AddSshKey> addSshKey) {
+ AddSshKey(
+ Provider<com.google.gerrit.server.restapi.account.AddSshKey> addSshKey,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.addSshKey = addSshKey;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
public Response<SshKeyInfo> apply(ServiceUserResource rsrc, SshKeyInput input)
throws AuthException, BadRequestException, IOException, ConfigInvalidException {
- return addSshKey.get().apply(rsrc.getUser(), input);
+ Response<SshKeyInfo> resp = addSshKey.get().apply(rsrc.getUser(), input);
+ if (resp.statusCode() == Response.created().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.ADD_SSH_KEY).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
index 199f2bf..449657a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
@@ -31,6 +31,8 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -42,6 +44,7 @@
private final com.google.gerrit.server.restapi.account.CreateToken createToken;
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
CreateToken(
@@ -49,11 +52,13 @@
PluginConfigFactory pluginConfigFactory,
com.google.gerrit.server.restapi.account.CreateToken createToken,
Provider<CurrentUser> self,
- PermissionBackend permissionBackend) {
+ PermissionBackend permissionBackend,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.config = pluginConfigFactory.getFromGerritConfig(pluginName);
this.createToken = createToken;
this.self = self;
this.permissionBackend = permissionBackend;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -72,6 +77,11 @@
}
}
- return createToken.apply(rsrc.getUser(), id.get(), input);
+ Response<AuthTokenInfo> resp =
+ createToken.apply(rsrc.getUser(), id.get(), input);
+ if (resp.statusCode() == Response.created().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.CREATE_TOKEN).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteActive.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteActive.java
index ec5d063..e90cece 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteActive.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteActive.java
@@ -21,21 +21,32 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
class DeleteActive implements RestModifyView<ServiceUserResource, Input> {
private final Provider<com.google.gerrit.server.restapi.account.DeleteActive> deleteActive;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
- DeleteActive(Provider<com.google.gerrit.server.restapi.account.DeleteActive> deleteActive) {
+ DeleteActive(
+ Provider<com.google.gerrit.server.restapi.account.DeleteActive> deleteActive,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.deleteActive = deleteActive;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
public Response<?> apply(ServiceUserResource rsrc, Input input)
throws RestApiException, IOException, ConfigInvalidException {
- return deleteActive.get().apply(rsrc, input);
+ Response<?> resp = deleteActive.get().apply(rsrc, input);
+
+ if (resp.statusCode() == Response.none().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.INACTIVATE).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteSshKey.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteSshKey.java
index 691f33f..338cae4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteSshKey.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteSshKey.java
@@ -22,6 +22,8 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -29,10 +31,14 @@
@Singleton
class DeleteSshKey implements RestModifyView<ServiceUserResource.SshKey, Input> {
private final Provider<com.google.gerrit.server.restapi.account.DeleteSshKey> deleteSshKey;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
- DeleteSshKey(Provider<com.google.gerrit.server.restapi.account.DeleteSshKey> deleteSshKey) {
+ DeleteSshKey(
+ Provider<com.google.gerrit.server.restapi.account.DeleteSshKey> deleteSshKey,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.deleteSshKey = deleteSshKey;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -42,6 +48,11 @@
IOException,
ConfigInvalidException,
PermissionBackendException {
- return deleteSshKey.get().apply(rsrc.getUser(), rsrc.getSshKey());
+ Response<?> resp = deleteSshKey.get().apply(rsrc.getUser(), rsrc.getSshKey());
+
+ if (resp.statusCode() == Response.none().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.DELETE_SSH_KEY).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java
index bc38f48..8662fa3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java
@@ -29,6 +29,8 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -38,6 +40,7 @@
private final com.google.gerrit.server.restapi.account.DeleteToken deleteToken;
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
DeleteToken(
@@ -45,11 +48,13 @@
PluginConfigFactory pluginConfigFactory,
com.google.gerrit.server.restapi.account.DeleteToken deleteToken,
Provider<CurrentUser> self,
- PermissionBackend permissionBackend) {
+ PermissionBackend permissionBackend,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.config = pluginConfigFactory.getFromGerritConfig(pluginName);
this.deleteToken = deleteToken;
this.self = self;
this.permissionBackend = permissionBackend;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -64,6 +69,10 @@
permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
}
- return deleteToken.apply(rsrc.getUser(), rsrc.getToken().id(), false);
+ Response<String> resp = deleteToken.apply(rsrc.getUser(), rsrc.getToken().id(), false);
+ if (resp.statusCode() == Response.none().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.DELETE_TOKEN).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
index f0b92dd..d71adf6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetServiceUser.java
@@ -37,7 +37,7 @@
import org.eclipse.jgit.lib.Config;
@Singleton
-class GetServiceUser implements RestReadView<ServiceUserResource> {
+public class GetServiceUser implements RestReadView<ServiceUserResource> {
private final Provider<GetAccount> getAccount;
private final GetOwner getOwner;
private final AccountLoader.Factory accountLoader;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Init.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Init.java
new file mode 100644
index 0000000..e5027fe
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Init.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2025 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.serviceuser;
+
+import static com.google.gerrit.common.FileUtil.chmod;
+import static com.google.gerrit.pgm.init.api.InitUtil.extract;
+
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserEmailModule;
+import java.nio.file.Path;
+
+public class Init implements InitStep {
+ private final SitePaths site;
+
+ @Inject
+ Init(SitePaths site) {
+ this.site = site;
+ }
+
+ @Override
+ public void run() throws Exception {
+ extractMail("ServiceUserUpdated.soy");
+ extractMail("ServiceUserUpdatedHtml.soy");
+ }
+
+ private void extractMail(String orig) throws Exception {
+ Path ex = site.mail_dir.resolve(orig);
+ extract(ex, ServiceUserEmailModule.class, orig);
+ chmod(0444, ex);
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
index fbfc1c0..6e189fd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
@@ -33,6 +33,7 @@
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.assistedinject.FactoryModuleBuilder;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserEmailModule;
class Module extends AbstractModule {
@@ -82,6 +83,7 @@
});
install(new HttpModule());
install(StorageCache.module());
+ install(new ServiceUserEmailModule());
}
@Provides
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutActive.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutActive.java
index f4b0365..6b3b332 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutActive.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutActive.java
@@ -21,21 +21,33 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
class PutActive implements RestModifyView<ServiceUserResource, Input> {
private final Provider<com.google.gerrit.server.restapi.account.PutActive> putActive;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
- PutActive(Provider<com.google.gerrit.server.restapi.account.PutActive> putActive) {
+ PutActive(
+ Provider<com.google.gerrit.server.restapi.account.PutActive> putActive,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.putActive = putActive;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
public Response<String> apply(ServiceUserResource rsrc, Input input)
throws IOException, ConfigInvalidException, RestApiException {
- return putActive.get().apply(rsrc, input);
+
+ Response<String> resp = putActive.get().apply(rsrc, input);
+ if (resp.statusCode() == Response.created().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.ACTIVATE).send();
+ }
+
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutEmail.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutEmail.java
index cb66be7..49ec008 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutEmail.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutEmail.java
@@ -36,6 +36,8 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.serviceuser.PutEmail.Input;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -52,6 +54,7 @@
private final Provider<PutPreferred> putPreferred;
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
PutEmail(
@@ -61,7 +64,8 @@
Provider<DeleteEmail> deleteEmail,
Provider<PutPreferred> putPreferred,
Provider<CurrentUser> self,
- PermissionBackend permissionBackend) {
+ PermissionBackend permissionBackend,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.getConfig = getConfig;
this.getEmail = getEmail;
this.createEmail = createEmail;
@@ -69,6 +73,7 @@
this.putPreferred = putPreferred;
this.self = self;
this.permissionBackend = permissionBackend;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -95,11 +100,16 @@
throw asRestApiException("Cannot get email", e);
}
+ Response<?> resp;
if (Strings.emptyToNull(input.email) == null) {
if (Strings.emptyToNull(email) == null) {
return Response.none();
}
- return deleteEmail.get().apply(rsrc.getUser(), email);
+ resp = deleteEmail.get().apply(rsrc.getUser(), email);
+ if (resp.statusCode() == Response.none().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.DELETE_EMAIL).send();
+ }
+ return resp;
} else if (email != null && email.equals(input.email)) {
return Response.ok(email);
} else {
@@ -111,6 +121,9 @@
in.noConfirmation = true;
createEmail.get().apply(rsrc.getUser(), IdString.fromDecoded(in.email), in);
putPreferred.get().apply(rsrc.getUser(), input.email);
+
+ outgoingEmailFactory.create(rsrc, Operation.ADD_EMAIL).send();
+
return Response.ok(input.email);
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutName.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutName.java
index 2906daa..63bb8d0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutName.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutName.java
@@ -22,16 +22,22 @@
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
class PutName implements RestModifyView<ServiceUserResource, NameInput> {
private Provider<com.google.gerrit.server.restapi.account.PutName> putName;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
- PutName(Provider<com.google.gerrit.server.restapi.account.PutName> putName) {
+ PutName(
+ Provider<com.google.gerrit.server.restapi.account.PutName> putName,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.putName = putName;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -40,6 +46,12 @@
ResourceNotFoundException,
IOException,
ConfigInvalidException {
- return putName.get().apply(rsrc.getUser(), input);
+ Response<String> resp = putName.get().apply(rsrc.getUser(), input);
+ if (resp.statusCode() == Response.none().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.DELETE_NAME).send();
+ } else if (resp.statusCode() == Response.ok().statusCode()) {
+ outgoingEmailFactory.create(rsrc, Operation.UPDATE_NAME).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
index e3af6a0..0c57bb9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutOwner.java
@@ -45,6 +45,8 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.serviceuser.PutOwner.Input;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserOutgoingEmail;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
@@ -64,6 +66,7 @@
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
private final StorageCache storageCache;
+ private final ServiceUserOutgoingEmail.Factory outgoingEmailFactory;
@Inject
PutOwner(
@@ -75,7 +78,8 @@
GroupJson json,
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
- StorageCache storageCache) {
+ StorageCache storageCache,
+ ServiceUserOutgoingEmail.Factory outgoingEmailFactory) {
this.getConfig = getConfig;
this.groups = groups;
this.configProvider = configProvider;
@@ -85,6 +89,7 @@
this.self = self;
this.permissionBackend = permissionBackend;
this.storageCache = storageCache;
+ this.outgoingEmailFactory = outgoingEmailFactory;
}
@Override
@@ -130,10 +135,18 @@
throw asRestApiException("Invalid configuration", e);
}
- return group != null
- ? (oldGroup != null
- ? Response.ok(json.format(group))
- : Response.created(json.format(group)))
- : Response.<GroupInfo>none();
+ Response<GroupInfo> resp;
+ if (group != null) {
+ if (oldGroup != null) {
+ resp = Response.ok(json.format(group));
+ } else {
+ resp = Response.created(json.format(group));
+ }
+ outgoingEmailFactory.create(rsrc, Operation.UPDATE_OWNER).send();
+ } else {
+ resp = Response.none();
+ outgoingEmailFactory.create(rsrc, Operation.DELETE_OWNER).send();
+ }
+ return resp;
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
index 5e43f35..48ab903 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
@@ -21,7 +21,7 @@
import com.google.gerrit.server.account.AuthToken;
import com.google.inject.TypeLiteral;
-class ServiceUserResource extends AccountResource {
+public class ServiceUserResource extends AccountResource {
static final TypeLiteral<RestView<ServiceUserResource>> SERVICE_USER_KIND =
new TypeLiteral<RestView<ServiceUserResource>>() {};
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java
new file mode 100644
index 0000000..072dbff
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserEmailModule.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2025 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.serviceuser.email;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
+
+public class ServiceUserEmailModule extends FactoryModule {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), MailSoyTemplateProvider.class)
+ .to(ServiceUserMailSoyTemplateProvider.class)
+ .in(SINGLETON);
+ factory(ServiceUserUpdatedEmailDecorator.Factory.class);
+ factory(ServiceUserOutgoingEmail.Factory.class);
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserMailSoyTemplateProvider.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserMailSoyTemplateProvider.java
new file mode 100644
index 0000000..63ddf13
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserMailSoyTemplateProvider.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2025 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.serviceuser.email;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
+import java.util.Set;
+
+public class ServiceUserMailSoyTemplateProvider implements MailSoyTemplateProvider {
+
+ @Override
+ public String getPath() {
+ return "com/googlesource/gerrit/plugins/serviceuser/email/";
+ }
+
+ @Override
+ public Set<String> getFileNames() {
+ return ImmutableSet.of("ServiceUserUpdated.soy", "ServiceUserUpdatedHtml.soy");
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserOutgoingEmail.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserOutgoingEmail.java
new file mode 100644
index 0000000..9b55e6c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserOutgoingEmail.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2025 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.serviceuser.email;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.server.mail.send.OutgoingEmailFactory;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource;
+import com.googlesource.gerrit.plugins.serviceuser.email.ServiceUserUpdatedEmailDecorator.Operation;
+
+public class ServiceUserOutgoingEmail {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final ServiceUserUpdatedEmailDecorator.Factory emailDecoratorFactory;
+ private final OutgoingEmailFactory outgoingEmailFactory;
+
+ private final ServiceUserResource serviceUserResource;
+ private final Operation operation;
+
+ public interface Factory {
+ ServiceUserOutgoingEmail create(ServiceUserResource serviceUserResource, Operation operation);
+ }
+
+ @AssistedInject
+ public ServiceUserOutgoingEmail(
+ ServiceUserUpdatedEmailDecorator.Factory emailDecoratorFactory,
+ OutgoingEmailFactory outgoingEmailFactory,
+ @Assisted ServiceUserResource serviceUserResource,
+ @Assisted Operation operation) {
+ this.emailDecoratorFactory = emailDecoratorFactory;
+ this.outgoingEmailFactory = outgoingEmailFactory;
+
+ this.serviceUserResource = serviceUserResource;
+ this.operation = operation;
+ }
+
+ public void send() {
+ try {
+ outgoingEmailFactory
+ .create(
+ "ServiceUserUpdated", emailDecoratorFactory.create(serviceUserResource, operation))
+ .send();
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log("Failed to send email about serviceuser update");
+ }
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedEmailDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedEmailDecorator.java
new file mode 100644
index 0000000..8d00065
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedEmailDecorator.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2025 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.serviceuser.email;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.group.ListMembers;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import com.googlesource.gerrit.plugins.serviceuser.GetServiceUser;
+import com.googlesource.gerrit.plugins.serviceuser.GetServiceUser.ServiceUserInfo;
+import com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource;
+import java.io.IOException;
+
+public class ServiceUserUpdatedEmailDecorator implements EmailDecorator {
+ public enum Operation {
+ ADD_SSH_KEY("An SSH key was added."),
+ CREATE_TOKEN("An authentication token was created."),
+ INACTIVATE("The service user was inactivated."),
+ DELETE_SSH_KEY("An SSH key was deleted."),
+ DELETE_TOKEN("An authentication token was deleted."),
+ ACTIVATE("The service user was activated."),
+ DELETE_EMAIL("The email address was deleted."),
+ ADD_EMAIL("The email address was updated."),
+ DELETE_NAME("The full name was deleted."),
+ UPDATE_NAME("The full name was updated."),
+ UPDATE_OWNER("The owner group was updated."),
+ DELETE_OWNER("The owner group was deleted.");
+
+ public final String description;
+
+ Operation(String description) {
+ this.description = description;
+ }
+ }
+
+ public interface Factory {
+ ServiceUserUpdatedEmailDecorator create(
+ ServiceUserResource serviceUserResource, Operation operation);
+ }
+
+ private OutgoingEmail email;
+
+ private final Provider<GetServiceUser> getServiceUser;
+ private final Provider<ListMembers> listMembers;
+ private final OneOffRequestContext oneOffRequestContext;
+ private final GroupControl.Factory groupControlFactory;
+ private final GroupResolver groupResolver;
+ private final MessageIdGenerator messageIdGenerator;
+ private final DefaultUrlFormatter urlFomatter;
+
+ private final ServiceUserResource serviceUserResource;
+ private final Operation operation;
+
+ private ServiceUserInfo serviceUserInfo;
+
+ @AssistedInject
+ public ServiceUserUpdatedEmailDecorator(
+ Provider<GetServiceUser> getServiceUser,
+ Provider<ListMembers> listMembers,
+ OneOffRequestContext oneOffRequestContext,
+ GroupControl.Factory groupControlFactory,
+ GroupResolver groupResolver,
+ MessageIdGenerator messageIdGenerator,
+ DefaultUrlFormatter urlFomatter,
+ @Assisted ServiceUserResource serviceUserResource,
+ @Assisted Operation operation) {
+ this.getServiceUser = getServiceUser;
+ this.listMembers = listMembers;
+ this.oneOffRequestContext = oneOffRequestContext;
+ this.groupControlFactory = groupControlFactory;
+ this.groupResolver = groupResolver;
+ this.messageIdGenerator = messageIdGenerator;
+ this.urlFomatter = urlFomatter;
+
+ this.serviceUserResource = serviceUserResource;
+ this.operation = operation;
+ }
+
+ @Override
+ public void init(OutgoingEmail email) throws EmailException {
+ this.email = email;
+
+ try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+ try {
+ serviceUserInfo = getServiceUser.get().apply(serviceUserResource).value();
+ } catch (IOException | RestApiException | PermissionBackendException e) {
+ throw new EmailException(
+ String.format(
+ "Failed to get service user details for %s",
+ serviceUserResource.getUser().getUserName()),
+ e);
+ }
+ }
+
+ this.email.setHeader(
+ "Subject",
+ String.format(
+ "[Gerrit Code Review] Service User '%s' has been updated.", serviceUserInfo.username));
+ this.email.setMessageId(
+ messageIdGenerator.fromReasonAccountIdAndTimestamp(
+ "Serviceuser_updated", Account.id(serviceUserInfo._accountId), TimeUtil.now()));
+
+ if (serviceUserInfo.owner == null) {
+ this.email.addByAccountId(RecipientType.TO, Account.id(serviceUserInfo.createdBy._accountId));
+ } else {
+ try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+ GroupDescription.Basic group = groupResolver.parseId(serviceUserInfo.owner.id);
+ GroupControl ctl = groupControlFactory.controlFor(group);
+ ListMembers lm = listMembers.get();
+ GroupResource rsrc = new GroupResource(ctl);
+ lm.setRecursive(true);
+ try {
+ for (AccountInfo a : lm.apply(rsrc).value()) {
+ this.email.addByAccountId(RecipientType.TO, Account.id(a._accountId));
+ }
+ } catch (Exception e) {
+ throw new EmailException(
+ "Could not compute receipients for serviceuser update notice.", e);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void populateEmailContent() throws EmailException {
+ email.addSoyEmailDataParam("serviceUserName", serviceUserInfo.username);
+ email.addSoyEmailDataParam("operation", operation.description);
+ email.addSoyEmailDataParam(
+ "serviceUserUrl",
+ urlFomatter
+ .getRestUrl(String.format("x/serviceuser/user/%d", serviceUserInfo._accountId))
+ .get());
+
+ email.appendText(email.textTemplate("ServiceUserUpdated"));
+ if (email.useHtml()) {
+ email.appendHtml(email.soyHtmlTemplate("ServiceUserUpdatedHtml"));
+ }
+ }
+}
diff --git a/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdated.soy b/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdated.soy
new file mode 100644
index 0000000..a6ff0e9
--- /dev/null
+++ b/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdated.soy
@@ -0,0 +1,59 @@
+/**
+ * Copyright (C) 2025 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.google.gerrit.server.mail.template.ServiceUserUpdated}
+
+/**
+ * The .AuthTokenUpdate template will determine the contents of the email related to
+ * adding, changing or deleting the token.
+ */
+{template ServiceUserUpdated kind="text"}
+ {@param email: ?}
+ The serviceuser with username "{$email.serviceUserName}" was updated{sp}
+ on Gerrit Code Review at {sp}{$email.gerritHost}. The following change was made:
+
+ {\n}
+ {\n}
+ {$email.operation}
+ {\n}
+ {\n}
+
+ If this is not expected, please contact your Gerrit Administrators
+ immediately.
+
+ {\n}
+ {\n}
+
+ You can manage this serviceuser by visiting
+ {\n}
+ {$email.serviceUserUrl}.
+ {\n}
+
+ {\n}
+ {\n}
+
+ If clicking the link above does not work, copy and paste the URL in a new
+ browser window instead.
+
+ {\n}
+ {\n}
+
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+
+ {\n}
+ {\n}
+{/template}
diff --git a/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedHtml.soy b/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedHtml.soy
new file mode 100644
index 0000000..da042c1
--- /dev/null
+++ b/src/main/resources/com/googlesource/gerrit/plugins/serviceuser/email/ServiceUserUpdatedHtml.soy
@@ -0,0 +1,47 @@
+/**
+ * Copyright (C) 2025 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.google.gerrit.server.mail.template.ServiceUserUpdatedHtml}
+
+{template ServiceUserUpdatedHtml}
+ {@param email: ?}
+ <p>
+ The serviceuser with username "{$email.serviceUserName}" was updated{sp}
+ on Gerrit Code Review at {sp}{$email.gerritHost}. The following change was made:
+ </p>
+
+ <p><i>{$email.operation}</i></p>
+
+ <p>
+ If this is not expected, please contact your Gerrit Administrators
+ immediately.
+ </p>
+
+ <p>
+ You can manage this serviceuser by visiting {sp}
+ <a href="{$email.serviceUserUrl}">{$email.serviceUserUrl}</a>.
+ </p>
+
+ <p>
+ If clicking the link above does not work, copy and paste the URL in a new
+ browser window instead.
+ </p>
+
+ <p>
+ This is a send-only email address. Replies to this message will not be read
+ or answered.
+ </p>
+{/template}