diff --git a/src/actions/__tests__/event-actions-notify.test.js b/src/actions/__tests__/event-actions-notify.test.js
new file mode 100644
index 000000000..15c8ec259
--- /dev/null
+++ b/src/actions/__tests__/event-actions-notify.test.js
@@ -0,0 +1,92 @@
+import configureStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import { putRequest } from "openstack-uicore-foundation/lib/utils/actions";
+import T from "i18n-react/dist/i18n-react";
+import * as methods from "../../utils/methods";
+import { notifySubmissionReopened } from "../event-actions";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: jest.fn((key) => key) }
+}));
+
+jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
+ __esModule: true,
+ ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
+ putRequest: jest.fn()
+}));
+
+const mockStore = configureStore([thunk]);
+
+describe("notifySubmissionReopened", () => {
+ const summitId = 5;
+ let store;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ window.API_BASE_URL = "https://api.test";
+ store = mockStore({
+ currentSummitState: { currentSummit: { id: summitId } }
+ });
+ jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
+ });
+
+ const arrangeRequest = (
+ result = Promise.resolve({ response: { recipients: 3 } })
+ ) => {
+ putRequest.mockReturnValue(() => () => result);
+ };
+
+ it("PUTs the selection to the notify endpoint in the API's snake_case shape", async () => {
+ arrangeRequest();
+
+ await store.dispatch(
+ notifySubmissionReopened(42, {
+ speakerIds: [7, 12],
+ includeSubmitter: true
+ })
+ );
+
+ expect(putRequest).toHaveBeenCalledTimes(1);
+ const [requestAction, , url, body] = putRequest.mock.calls[0];
+ expect(requestAction).toBeNull();
+ expect(url).toBe(
+ `https://api.test/api/v1/summits/${summitId}/presentations/42/submission-period/reopen/notify`
+ );
+ expect(body).toEqual({ speaker_ids: [7, 12], include_submitter: true });
+ });
+
+ it("reports the recipient count from the response, not a client tally", async () => {
+ arrangeRequest(Promise.resolve({ response: { recipients: 3 } }));
+
+ await store.dispatch(
+ notifySubmissionReopened(42, { speakerIds: [7], includeSubmitter: false })
+ );
+
+ expect(T.translate).toHaveBeenCalledWith(
+ "edit_event.notify_speakers_success",
+ { count: 3 }
+ );
+
+ const snackbar = store
+ .getActions()
+ .find((a) => a.type === "SET_SNACKBAR_MESSAGE");
+ expect(snackbar).toBeDefined();
+ expect(snackbar.payload.type).toBe("success");
+ });
+
+ it("stops loading even when the request rejects", async () => {
+ arrangeRequest(Promise.reject(new Error("412")));
+
+ await expect(
+ store.dispatch(
+ notifySubmissionReopened(42, {
+ speakerIds: [7],
+ includeSubmitter: false
+ })
+ )
+ ).rejects.toThrow();
+
+ expect(store.getActions().map((a) => a.type)).toContain("STOP_LOADING");
+ });
+});
diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js
index 338c6f333..8a6b1ede5 100644
--- a/src/actions/event-actions.js
+++ b/src/actions/event-actions.js
@@ -83,6 +83,7 @@ export const RECEIVE_EVENT_COMMENTS = "RECEIVE_EVENT_COMMENTS";
export const CHANGE_SEARCH_TERM = "CHANGE_SEARCH_TERM";
export const SUBMISSION_PERIOD_REOPENED = "SUBMISSION_PERIOD_REOPENED";
export const SUBMISSION_PERIOD_CLOSED = "SUBMISSION_PERIOD_CLOSED";
+export const SUBMISSION_REOPEN_NOTIFIED = "SUBMISSION_REOPEN_NOTIFIED";
export const ATTENDEES_EXPECTED_LEARNT = "attendees_expected_learnt";
export const ATTENDING_MEDIA = "attending_media";
@@ -815,6 +816,40 @@ export const closeSubmissionPeriod =
});
};
+export const notifySubmissionReopened =
+ (eventId, { speakerIds, includeSubmitter }) =>
+ async (dispatch, getState) => {
+ const { currentSummitState } = getState();
+
+ dispatch(startLoading());
+
+ const accessToken = await getAccessTokenSafely();
+ const { currentSummit } = currentSummitState;
+
+ const params = { access_token: accessToken };
+
+ return putRequest(
+ null,
+ createAction(SUBMISSION_REOPEN_NOTIFIED),
+ `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/presentations/${eventId}/submission-period/reopen/notify`,
+ { speaker_ids: speakerIds, include_submitter: includeSubmitter },
+ snackbarErrorHandler
+ )(params)(dispatch)
+ .then((payload) => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.success"),
+ html: T.translate("edit_event.notify_speakers_success", {
+ count: payload?.response?.recipients ?? 0
+ })
+ })
+ );
+ })
+ .finally(() => {
+ dispatch(stopLoading());
+ });
+ };
+
export const cloneEvent = (entity) => async (dispatch, getState) => {
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
diff --git a/src/components/forms/event-form/ReopenNotifyPanel.jsx b/src/components/forms/event-form/ReopenNotifyPanel.jsx
new file mode 100644
index 000000000..e9ab5f666
--- /dev/null
+++ b/src/components/forms/event-form/ReopenNotifyPanel.jsx
@@ -0,0 +1,89 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * 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.
+ * */
+
+import React from "react";
+import PropTypes from "prop-types";
+import T from "i18n-react/dist/i18n-react";
+import { ROLE } from "./utils";
+
+const ROLE_LABEL = {
+ [ROLE.SUBMITTER]: "edit_event.notify_role_submitter",
+ [ROLE.MODERATOR]: "edit_event.notify_role_moderator",
+ [ROLE.SPEAKER]: "edit_event.notify_role_speaker"
+};
+
+const ReopenNotifyPanel = ({
+ rows,
+ checked,
+ onToggle,
+ canNotify,
+ onNotify
+}) => (
+
+
+
+ {rows.map((row) => (
+
+ onToggle(row.key)}
+ />
+
+ {row.disabled && (
+
+
+ {T.translate("edit_event.notify_no_email")}
+
+ )}
+
+ ))}
+
+
+
+);
+
+ReopenNotifyPanel.propTypes = {
+ rows: PropTypes.array.isRequired,
+ checked: PropTypes.array.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ canNotify: PropTypes.bool.isRequired,
+ onNotify: PropTypes.func.isRequired
+};
+
+export default ReopenNotifyPanel;
diff --git a/src/components/forms/event-form/__tests__/event-form-notify.test.js b/src/components/forms/event-form/__tests__/event-form-notify.test.js
new file mode 100644
index 000000000..e11676541
--- /dev/null
+++ b/src/components/forms/event-form/__tests__/event-form-notify.test.js
@@ -0,0 +1,269 @@
+import React from "react";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import moment from "moment-timezone";
+import EventForm from "../index";
+import currentSummitMock from "../../../../__mocks__/currentSummitMock";
+import showConfirmDialog from "../../../mui/showConfirmDialog";
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("../../../mui/showConfirmDialog", () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/member-input",
+ () => ({
+ __esModule: true,
+ default: ({ id, onChange }) => (
+