Commit 6befb768a30 for woocommerce

commit 6befb768a30918ce953a5e2f6e0c83542ff5b0bf
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Thu Sep 24 07:24:47 2026 +0200

    Drop the view-post link from the email editor save notice (#68944)

    * Drop the view-post link from the email save notice

    Saving an email in the block editor showed a snackbar with a link to
    the post permalink, labelled with the post type's view_item label —
    "View Email", or "View Post" in integrations that do not set their own.
    An email is not a web page, and the editor header already offers a
    preview, so the link was both wrong and redundant.

    The override table already supported removing a notice's actions, but
    that only ran when the notice text matched the rewrite condition.
    Action removal now happens whenever the notice id matches, so the link
    is gone for every save variant — published, updated, and draft — and in
    every locale, regardless of whether the text rewrite applies.

    * Match the email save notice against the post type labels

    The override that rewrites the save snackbar to "Email saved." only
    fired when the notice text matched the English literals "Post updated."
    and "Post published.". Those strings never reach the browser as English
    on a translated site: WordPress builds the message from the post type's
    item_updated and item_published labels, which PHP translates on the
    server. The literals themselves appear in no JavaScript source, so they
    are in no JavaScript translation catalogue and __() returns them
    unchanged. The comparison could therefore only succeed on an
    English-language store, leaving every other site with the raw
    "Post updated." wording.

    The override now compares the notice against the post type's own
    labels, read from the core data store, so it matches whatever wording
    WordPress produced. The label set also covers the privately published
    and scheduled variants, which the literal check never reached. When the
    labels are not loaded the text is left alone and only the link is
    removed.

    * Add changelog entries for the email editor save notice fixes

    * Only read the post type when a notice is overridable

    Reading the post type labels on every getNotices call made each caller
    register the core data store as a listened store, so the notice
    components re-ran their selectors on every entity edit — that is, on
    every keystroke in the editor. The lookup now happens only when the
    notices actually contain an id the hook overrides.

    The core store lookup is also optional-chained, matching the guard on
    the sibling call. The hook replaces select for the whole registry, so
    an unresolvable core store would have thrown on every getNotices call
    in the application, not only in the email editor.

    The store name now comes from the constants module rather than the
    store barrel, which registers the store as a side effect and pulled
    @wordpress/components into a leaf hook. The spec no longer has to mock
    the whole module to load.

    Labels fall back to a shared empty object so the memoized selector's
    dependants stay object-like, and new tests cover the unresolved post
    type, the unregistered stores, and that a label is matched exactly
    rather than as a substring.

    * Keep the template editor's own save wording

    The email template editor reports its post type as wp_template, whose
    "Template updated." label matched the new label check and was rewritten
    to "Email saved.". That wording belongs to emails, not to designs, and
    the package already has its own string for design saves. The rewrite
    now skips template post types and leaves WordPress's wording alone. The
    link is still removed there, as it is for every save notice.

    Also drops the empty-labels fallback added with the previous commit.
    Its comment claimed rememo abandons its per-dependant cache when a
    dependant is not object-like, which is not what rememo does: it stops
    at the first such dependant and keeps the levels above it, so the
    notices array still gets its own cache entry. The test written to pin
    the fallback passed with the fallback removed, so it went too.

    * Narrow the save notice rewrite to the reachable labels

    Two corrections to the override table, both from review.

    The scheduled and privately-published labels were listed among the
    texts the hook rewrites, so a scheduled email would have reported
    "Email saved." and lost the confirmation that it is queued to send.
    Neither status is reachable: the editor removes the post-status panel
    on mount, leaving no way to set a future date or private visibility, so
    both labels are dropped as configuration that can never match.

    The override table was also read with a plain property lookup, so a
    notice whose id happened to be an Object.prototype member name, such as
    constructor or toString, resolved to an inherited function rather than
    to nothing. The hook then treated it as overridable and blanked its
    text. Both lookups now test for an own property.

    * Use the design wording when saving an email template

    Saving the email design reported two different things depending on what
    had changed. Editing the styles marks the global styles entity dirty, so
    the save goes through the multi-entity path and reports "Email design
    updated.". Saving the template content goes through the ordinary post
    save instead, and its notice is built from the template post type's
    labels, so it read "Template updated." — WordPress's generic wording, in
    the middle of the email editor.

    Template post types now get that same "Email design updated." wording
    rather than being left alone. The earlier reason for skipping them
    stands: a design save must not claim the email itself was saved. It
    needed the right message, though, not no message. Both overrides read
    from one constant so the two paths cannot drift apart.

    * Record why the trash and revert labels are not matched

    The override rewrites a save notice by comparing it against the post
    type's labels, and a reader looking at that list will wonder why the
    trashed and reverted-to-draft labels are missing, since both would
    otherwise show WordPress's generic post wording in the email editor.

    Neither can reach an editor-save notice. Trashing an email runs through
    a custom action that deletes the record and raises its own notice, and
    there is no way to move a published email back to draft once the
    post-status panel is removed. Note it so the next reader does not add
    wording for states nobody can get into.

diff --git a/packages/js/email-editor/changelog/stomail-8517-localized-save-notice b/packages/js/email-editor/changelog/stomail-8517-localized-save-notice
new file mode 100644
index 00000000000..624be8ef360
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-localized-save-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the email editor save notice in the site language instead of the untranslated post wording
diff --git a/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link b/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link
new file mode 100644
index 00000000000..684c8ab6b75
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-remove-save-notice-link
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove the view-post link from the notice shown after saving an email in the email editor
diff --git a/packages/js/email-editor/changelog/stomail-8517-template-save-wording b/packages/js/email-editor/changelog/stomail-8517-template-save-wording
new file mode 100644
index 00000000000..3201cd3a5e1
--- /dev/null
+++ b/packages/js/email-editor/changelog/stomail-8517-template-save-wording
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Report the same wording when saving the email design from either the editor or the template itself
diff --git a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
index de7619c0a10..f4eac360601 100644
--- a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
+++ b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
@@ -7,6 +7,7 @@ import { renderHook } from '@testing-library/react';
  * Internal dependencies
  */
 import { useNoticeOverrides } from '../use-notice-overrides';
+import { storeName as EMAIL_EDITOR_STORE_NAME } from '../../store/constants';

 // Keep a reference to the plugin callback registered via `use()`.
 let capturedPlugin: ( registry: {
@@ -38,6 +39,10 @@ jest.mock( '@wordpress/notices', () => ( {
 	store: { name: 'core/notices' },
 } ) );

+jest.mock( '@wordpress/core-data', () => ( {
+	store: { name: 'core' },
+} ) );
+
 jest.mock( '@wordpress/i18n', () => ( {
 	__: ( text: string ) => text,
 } ) );
@@ -49,6 +54,15 @@ interface Notice {
 	actions: unknown[];
 }

+type Labels = Record< string, string > | undefined;
+
+// `originalSelect` is called with either a store name string (from the
+// plugin's own `select( namespace )`) or a store descriptor object (from
+// the hook's own `originalSelect( coreStore )` / `originalSelect( storeName )`
+// calls) — normalise both to a name for routing in the test doubles below.
+const resolveStoreName = ( ns: string | { name: string } ): string =>
+	typeof ns === 'object' ? ns.name : ns;
+
 const makeNotice = ( partial: Partial< Notice > = {} ): Notice => ( {
 	id: 'test-notice',
 	content: 'Test notice',
@@ -62,21 +76,68 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		jest.clearAllMocks();
 	} );

-	function buildSelectOverride( notices: Notice[] ) {
+	/**
+	 * Builds the `originalSelect` stub the plugin wraps, routing
+	 * `core/notices`, `core`, and the email editor store to separate
+	 * selector objects so the hook's cross-store label lookup can be
+	 * exercised.
+	 *
+	 * @param notices  Notices `core/notices`' `getNotices` should return.
+	 * @param labels   Labels `core`'s `getPostType` should return, keyed by
+	 *                 post type. `undefined` means the post type isn't
+	 *                 loaded yet.
+	 * @param postType Post type the email editor store's
+	 *                 `getEmailPostType` should return.
+	 */
+	function buildSelectOverride(
+		notices: Notice[],
+		labels?: Labels,
+		postType = 'email'
+	) {
 		const originalGetNotices = jest.fn().mockReturnValue( notices );
-		const originalSelectors = { getNotices: originalGetNotices };
+		const noticesSelectors = { getNotices: originalGetNotices };

-		const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+		const getPostType = jest
+			.fn()
+			.mockReturnValue( labels === undefined ? undefined : { labels } );
+		const coreSelectors = { getPostType };
+
+		const getEmailPostType = jest.fn().mockReturnValue( postType );
+		const emailEditorSelectors = { getEmailPostType };
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );

 		renderHook( () => useNoticeOverrides() );

 		const pluginResult = capturedPlugin( { select: originalSelect } );
-		return { pluginResult, originalSelect, originalGetNotices };
+		return {
+			pluginResult,
+			originalSelect,
+			originalGetNotices,
+			getPostType,
+			getEmailPostType,
+		};
 	}

-	it( 'getNotices returns the same array reference when notices input is unchanged', () => {
+	it( 'getNotices returns the same array reference when notices and labels are unchanged', () => {
 		const notices = [ makeNotice() ];
-		const { pluginResult } = buildSelectOverride( notices );
+		const { pluginResult } = buildSelectOverride( notices, {
+			item_updated: 'Post updated.',
+		} );

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
@@ -90,8 +151,30 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {

 	it( 'getNotices returns a new array reference when notices input changes', () => {
 		const originalGetNotices = jest.fn();
-		const originalSelectors = { getNotices: originalGetNotices };
-		const originalSelect = jest.fn().mockReturnValue( originalSelectors );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const coreSelectors = {
+			getPostType: jest.fn().mockReturnValue( {
+				labels: { item_updated: 'Post updated.' },
+			} ),
+		};
+		const emailEditorSelectors = {
+			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		};
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );

 		renderHook( () => useNoticeOverrides() );
 		const pluginResult = capturedPlugin( { select: originalSelect } );
@@ -110,6 +193,52 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( firstResult ).not.toBe( secondResult );
 	} );

+	it( 'getNotices returns a new array reference when the labels reference changes', () => {
+		// Uses an overridable notice id: the postType/labels lookup (finding 3)
+		// is skipped entirely for non-overridable notices, which would make
+		// this label change invisible to the memoized selector.
+		const notices = [ makeNotice( { id: 'editor-save' } ) ];
+		const originalGetNotices = jest.fn().mockReturnValue( notices );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const getPostType = jest
+			.fn()
+			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+		const coreSelectors = { getPostType };
+		const emailEditorSelectors = {
+			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		};
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+
+		const firstResult = selectors.getNotices();
+
+		getPostType.mockReturnValue( {
+			labels: { item_updated: 'Příspěvek byl aktualizován.' },
+		} );
+		const secondResult = selectors.getNotices();
+
+		expect( firstResult ).not.toBe( secondResult );
+	} );
+
 	it( 'passes through select for non-notices stores unchanged', () => {
 		const notices = [ makeNotice() ];
 		const otherSelectors = { getSomething: jest.fn() };
@@ -129,12 +258,16 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( result ).toBe( otherSelectors );
 	} );

-	it( 'transforms known notice content via getNotices', () => {
+	it( 'transforms an editor-save notice whose content matches the localized item_updated label', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
-			content: 'Post updated.',
+			content: 'Příspěvek byl aktualizován.',
+			actions: [ { label: 'Zobrazit e-mail', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Příspěvek byl aktualizován.',
+			view_item: 'Zobrazit e-mail',
 		} );
-		const { pluginResult } = buildSelectOverride( [ originalNotice ] );

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
@@ -142,16 +275,19 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		const result = selectors.getNotices();

 		expect( result[ 0 ].content ).toBe( 'Email saved.' );
+		expect( result[ 0 ].spokenMessage ).toBe( 'Email saved.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
 	} );

-	it( 'transforms an editor-save notice with "Post published." content', () => {
-		// Emitted when an integration's save button publishes the post in the
-		// background (lazy post creation) instead of a plain update.
+	it( 'transforms an editor-save notice matching a custom integration item_updated label', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
-			content: 'Post published.',
+			content: 'Email updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Email updated.',
 		} );
-		const { pluginResult } = buildSelectOverride( [ originalNotice ] );

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
@@ -161,45 +297,124 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( result[ 0 ].content ).toBe( 'Email saved.' );
 	} );

-	it( 'leaves an editor-save notice with "Draft saved." content unchanged', () => {
-		// A saved draft is not used for sending; rewriting the notice to
-		// "Email saved." would suggest the opposite.
+	it.each( [ [ 'item_published', 'Příspěvek byl publikován.' ] ] )(
+		'transforms an editor-save notice matching the localized %s label',
+		( labelKey, localizedText ) => {
+			const originalNotice = makeNotice( {
+				id: 'editor-save',
+				content: localizedText,
+				actions: [ { label: 'View', url: '#' } ],
+			} );
+			const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+				[ labelKey ]: localizedText,
+			} );
+
+			const selectors = pluginResult.select( 'core/notices' ) as {
+				getNotices: () => Notice[];
+			};
+			const result = selectors.getNotices();
+
+			expect( result[ 0 ].content ).toBe( 'Email saved.' );
+			expect( result[ 0 ].actions ).toEqual( [] );
+		}
+	);
+
+	it( 'leaves an editor-save notice unchanged when labels are not loaded yet, but still removes the action', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
-			content: 'Draft saved.',
+			content: 'Post updated.',
+			actions: [ { label: 'View Email', url: '#' } ],
 		} );
-		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+		const { pluginResult } = buildSelectOverride(
+			[ originalNotice ],
+			undefined
+		);

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
 		};
 		const result = selectors.getNotices();

-		expect( result[ 0 ].content ).toBe( 'Draft saved.' );
+		expect( result[ 0 ].content ).toBe( 'Post updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
 	} );

-	it( 'leaves an editor-save notice with unrelated content unchanged', () => {
+	it( 'leaves an editor-save notice with content matching no label unchanged', () => {
 		const originalNotice = makeNotice( {
 			id: 'editor-save',
-			content: 'Saving failed.',
+			content: 'Updating failed.',
+			actions: [],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Post updated.',
 		} );
-		const { pluginResult } = buildSelectOverride( [ originalNotice ] );

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
 		};
 		const result = selectors.getNotices();

-		expect( result[ 0 ].content ).toBe( 'Saving failed.' );
+		expect( result[ 0 ].content ).toBe( 'Updating failed.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
 	} );

-	it( 'transforms site-editor-save-success notice and removes actions', () => {
+	it.each( [
+		[ 'item_published_privately', 'Příspěvek byl publikován soukromě.' ],
+		[ 'item_scheduled', 'Příspěvek byl naplánován.' ],
+	] )(
+		'leaves an editor-save notice matching the localized %s label unchanged, since the editor has no UI to reach that status',
+		( labelKey, localizedText ) => {
+			const originalNotice = makeNotice( {
+				id: 'editor-save',
+				content: localizedText,
+				actions: [ { label: 'View', url: '#' } ],
+			} );
+			const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+				[ labelKey ]: localizedText,
+			} );
+
+			const selectors = pluginResult.select( 'core/notices' ) as {
+				getNotices: () => Notice[];
+			};
+			const result = selectors.getNotices();
+
+			expect( result[ 0 ].content ).toBe( localizedText );
+			expect( result[ 0 ].actions ).toEqual( [] );
+		}
+	);
+
+	it( 'leaves an editor-save notice with "Draft saved." content unchanged but removes the action', () => {
+		// A saved draft is not used for sending; rewriting the notice to
+		// "Email saved." would suggest the opposite. The view-post action is
+		// still dropped, since it points at a permalink that is not the email.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Draft saved.',
+			actions: [ { label: 'View Preview', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Post updated.',
+		} );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Draft saved.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'transforms site-editor-save-success notice and removes actions regardless of labels', () => {
 		const originalNotice = makeNotice( {
 			id: 'site-editor-save-success',
 			content: 'Site updated.',
 			actions: [ { label: 'View', url: '#' } ],
 		} );
-		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+		const { pluginResult } = buildSelectOverride(
+			[ originalNotice ],
+			undefined
+		);

 		const selectors = pluginResult.select( 'core/notices' ) as {
 			getNotices: () => Notice[];
@@ -220,4 +435,282 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		const result = pluginResult.select( 'core/notices' );
 		expect( result ).toBe( originalSelectors );
 	} );
+
+	it( 'does not rewrite content that merely contains the label as a substring', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated. Extra',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Post updated.',
+		} );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Post updated. Extra' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'skips the postType/labels lookup when the email post type is not set yet, but still removes the action', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const getPostType = jest
+			.fn()
+			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+		const coreSelectors = { getPostType };
+		const getEmailPostType = jest.fn().mockReturnValue( undefined );
+		const emailEditorSelectors = { getEmailPostType };
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Post updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+		expect( getPostType ).not.toHaveBeenCalled();
+	} );
+
+	it( 'leaves notices unchanged without throwing when the email editor store is not registered', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const coreSelectors = {
+			getPostType: jest.fn().mockReturnValue( {
+				labels: { item_updated: 'Post updated.' },
+			} ),
+		};
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				// Email editor store not registered.
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+
+		expect( () => selectors.getNotices() ).not.toThrow();
+		const result = selectors.getNotices();
+		expect( result[ 0 ].content ).toBe( 'Post updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'leaves notices unchanged without throwing when the core store selectors are unavailable', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post updated.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const emailEditorSelectors = {
+			getEmailPostType: jest.fn().mockReturnValue( 'email' ),
+		};
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				// Core store not resolvable in this registry.
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+
+		expect( () => selectors.getNotices() ).not.toThrow();
+		const result = selectors.getNotices();
+		expect( result[ 0 ].content ).toBe( 'Post updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'skips the postType/labels lookup entirely when no notice is overridable', () => {
+		const originalNotice = makeNotice( {
+			id: 'unrelated-notice',
+			content: 'Something else',
+		} );
+		const originalGetNotices = jest
+			.fn()
+			.mockReturnValue( [ originalNotice ] );
+		const noticesSelectors = { getNotices: originalGetNotices };
+		const getEmailPostType = jest.fn().mockReturnValue( 'email' );
+		const emailEditorSelectors = { getEmailPostType };
+		const getPostType = jest
+			.fn()
+			.mockReturnValue( { labels: { item_updated: 'Post updated.' } } );
+		const coreSelectors = { getPostType };
+
+		const originalSelect = jest
+			.fn()
+			.mockImplementation( ( ns: string | { name: string } ) => {
+				const name = resolveStoreName( ns );
+				if ( name === 'core/notices' ) {
+					return noticesSelectors;
+				}
+				if ( name === 'core' ) {
+					return coreSelectors;
+				}
+				if ( name === EMAIL_EDITOR_STORE_NAME ) {
+					return emailEditorSelectors;
+				}
+				return undefined;
+			} );
+
+		renderHook( () => useNoticeOverrides() );
+		const pluginResult = capturedPlugin( { select: originalSelect } );
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ] ).toBe( originalNotice );
+		expect( getEmailPostType ).not.toHaveBeenCalled();
+		expect( getPostType ).not.toHaveBeenCalled();
+	} );
+
+	it( 'leaves a notice whose id collides with an Object.prototype member completely untouched', () => {
+		const originalNotice = makeNotice( {
+			id: 'constructor',
+			content: 'Something else',
+			spokenMessage: 'Something else',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ], {
+			item_updated: 'Post updated.',
+		} );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Something else' );
+		expect( result[ 0 ].spokenMessage ).toBe( 'Something else' );
+		expect( result[ 0 ].actions ).toEqual( originalNotice.actions );
+	} );
+
+	it.each( [ 'wp_template', 'wp_template_part' ] )(
+		'transforms an editor-save notice matching item_updated for the %s post type to "Email design updated."',
+		( postType ) => {
+			const originalNotice = makeNotice( {
+				id: 'editor-save',
+				content: 'Template updated.',
+				actions: [ { label: 'View', url: '#' } ],
+			} );
+			const { pluginResult } = buildSelectOverride(
+				[ originalNotice ],
+				{ item_updated: 'Template updated.' },
+				postType
+			);
+
+			const selectors = pluginResult.select( 'core/notices' ) as {
+				getNotices: () => Notice[];
+			};
+			const result = selectors.getNotices();
+
+			expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+			expect( result[ 0 ].spokenMessage ).toBe( 'Email design updated.' );
+			expect( result[ 0 ].actions ).toEqual( [] );
+		}
+	);
+
+	it( 'transforms an editor-save notice matching item_published for the wp_template post type to "Email design updated."', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post published.',
+			actions: [ { label: 'View', url: '#' } ],
+		} );
+		const { pluginResult } = buildSelectOverride(
+			[ originalNotice ],
+			{ item_published: 'Post published.' },
+			'wp_template'
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email design updated.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
+
+	it( 'leaves an editor-save notice with content matching no label unchanged for a template post type', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Updating failed.',
+			actions: [],
+		} );
+		const { pluginResult } = buildSelectOverride(
+			[ originalNotice ],
+			{ item_updated: 'Template updated.' },
+			'wp_template'
+		);
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Updating failed.' );
+		expect( result[ 0 ].actions ).toEqual( [] );
+	} );
 } );
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
index 5b92698fd10..37ff9464ada 100644
--- a/packages/js/email-editor/src/hooks/use-notice-overrides.ts
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -5,6 +5,12 @@ import { useEffect } from '@wordpress/element';
 import { createSelector, use } from '@wordpress/data';
 import { __ } from '@wordpress/i18n';
 import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { storeName } from '../store/constants';

 /**
  * Wraps the `getNotices` selector on the notices store so that specific
@@ -18,24 +24,51 @@ import { store as noticesStore } from '@wordpress/notices';
 interface NoticeOverride {
 	content: string;
 	removeActions: boolean;
-	contentCheck?: ( content: string ) => boolean;
+	labelKeys?: string[];
 }

+// Shared with the `editor-save` override below so a template design save
+// and an in-editor design save report the exact same wording.
+const EMAIL_DESIGN_UPDATED_MESSAGE = __(
+	'Email design updated.',
+	__i18n_text_domain__
+);
+
 function getNoticeOverrides(): Record< string, NoticeOverride > {
 	return {
 		'site-editor-save-success': {
-			content: __( 'Email design updated.', __i18n_text_domain__ ),
+			content: EMAIL_DESIGN_UPDATED_MESSAGE,
 			removeActions: true,
 		},
 		'editor-save': {
 			content: __( 'Email saved.', __i18n_text_domain__ ),
-			removeActions: false,
-			// "Draft saved." is intentionally NOT rewritten: a saved draft is
-			// not used for sending, and "Email saved." would suggest it is.
-			contentCheck: ( content: string ) =>
-				// Intentionally without text domain to match the core translations.
-				content.includes( __( 'Post updated.' ) ) ||
-				content.includes( __( 'Post published.' ) ),
+			// Gutenberg attaches an action linking to the post permalink,
+			// labelled with the post type's `view_item` label, which reads
+			// as "View Post"/"View Email" for an email. Drop it: a preview
+			// is already available from the editor header.
+			removeActions: true,
+			// The notice text is rewritten only when it equals one of the
+			// post type's success labels, which WordPress translates on the
+			// server (so this works in any site locale). "Draft saved." is
+			// deliberately left as-is: a saved draft is not used for
+			// sending, and "Email saved." would suggest it is.
+			// `item_published_privately` and `item_scheduled` are omitted:
+			// the editor removes the "post-status" panel on mount (see
+			// `block-editor/editor.tsx`), so there is no UI to set an email
+			// post's visibility to private or its status to `future` — those
+			// labels can never match a real notice here.
+			// `item_trashed` and `item_reverted_to_draft` are omitted too:
+			// Gutenberg only puts them on an `editor-save` notice by way of
+			// its own `trashPost()` and status-change flows, both of which
+			// end in a `dispatch.savePost()` call. The editor doesn't use
+			// either — trashing an email goes through a custom action that
+			// calls `deleteEntityRecord()` directly and reports its own
+			// `trash-email-post-action` notice (see
+			// `components/header/trash-email-post.tsx`), and there is no UI
+			// to revert a published post to draft once the "post-status"
+			// panel is removed above. So these labels can never match a real
+			// `editor-save` notice either.
+			labelKeys: [ 'item_updated', 'item_published' ],
 		},
 	};
 }
@@ -48,25 +81,57 @@ interface Notice {
 	[ key: string ]: unknown;
 }

-function transformNotice( notice: Notice ): Notice {
+type PostTypeLabels = Record< string, string > | undefined;
+
+// Post types whose own save notices ("Template updated.", …) get their own
+// "Email design updated." wording instead of "Email saved." — that text is
+// reserved for actual email post types, since saving a template is a design
+// change, not a change to the email content itself.
+const TEMPLATE_POST_TYPES = [ 'wp_template', 'wp_template_part' ];
+
+function isTemplatePostType( postType: string | undefined ): boolean {
+	return !! postType && TEMPLATE_POST_TYPES.includes( postType );
+}
+
+function transformNotice(
+	notice: Notice,
+	labels: PostTypeLabels,
+	postType: string | undefined
+): Notice {
 	const overrides = getNoticeOverrides();
-	const override = overrides[ notice.id ];
-	if ( ! override ) {
-		return notice;
-	}
-	if ( override.contentCheck && ! override.contentCheck( notice.content ) ) {
+	// A plain lookup would resolve ids like `constructor` or `toString` to
+	// an inherited `Object.prototype` member instead of `undefined`.
+	if ( ! Object.prototype.hasOwnProperty.call( overrides, notice.id ) ) {
 		return notice;
 	}
+	const override = overrides[ notice.id ];
+
+	const rewriteText =
+		! override.labelKeys ||
+		override.labelKeys.some(
+			( key ) => labels?.[ key ] && labels[ key ] === notice.content
+		);
+
+	const content =
+		notice.id === 'editor-save' && isTemplatePostType( postType )
+			? EMAIL_DESIGN_UPDATED_MESSAGE
+			: override.content;
+
 	return {
 		...notice,
-		content: override.content,
-		spokenMessage: override.content,
+		...( rewriteText ? { content, spokenMessage: content } : {} ),
 		actions: override.removeActions ? [] : notice.actions,
 	};
 }

-function applyOverridesToNotices( notices: Notice[] ): Notice[] {
-	return notices.map( ( notice ) => transformNotice( notice ) );
+function applyOverridesToNotices(
+	notices: Notice[],
+	labels: PostTypeLabels,
+	postType: string | undefined
+): Notice[] {
+	return notices.map( ( notice ) =>
+		transformNotice( notice, labels, postType )
+	);
 }

 function getStoreName( namespace: string | { name: string } ): string {
@@ -74,8 +139,16 @@ function getStoreName( namespace: string | { name: string } ): string {
 }

 const getNoticesWithOverrides = createSelector(
-	( notices: Notice[] ) => applyOverridesToNotices( notices ),
-	( notices: Notice[] ) => [ notices ]
+	(
+		notices: Notice[],
+		labels: PostTypeLabels,
+		postType: string | undefined
+	) => applyOverridesToNotices( notices, labels, postType ),
+	(
+		notices: Notice[],
+		labels: PostTypeLabels,
+		postType: string | undefined
+	) => [ notices, labels, postType ]
 );

 /**
@@ -107,10 +180,50 @@ export function useNoticeOverrides(): void {

 					return {
 						...selectors,
-						getNotices: ( context?: string ) =>
-							getNoticesWithOverrides(
-								originalGetNotices( context )
-							),
+						getNotices: ( context?: string ) => {
+							const notices = originalGetNotices( context );
+							const overrides = getNoticeOverrides();
+							const hasOverridableNotice = notices.some(
+								( notice ) =>
+									Object.prototype.hasOwnProperty.call(
+										overrides,
+										notice.id
+									)
+							);
+
+							if ( ! hasOverridableNotice ) {
+								return getNoticesWithOverrides(
+									notices,
+									undefined,
+									undefined
+								);
+							}
+
+							const postType = (
+								originalSelect( storeName ) as
+									| { getEmailPostType?: () => string }
+									| undefined
+							 )?.getEmailPostType?.();
+							const labels = postType
+								? (
+										originalSelect( coreStore ) as
+											| {
+													getPostType: (
+														postType: string
+													) => {
+														labels?: PostTypeLabels;
+													};
+											  }
+											| undefined
+								   )?.getPostType( postType )?.labels
+								: undefined;
+
+							return getNoticesWithOverrides(
+								notices,
+								labels,
+								postType
+							);
+						},
 					};
 				},
 			};