This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new af4dc3a9aa6 fix(re-encrypt): handle non-id PKs and make command 
idempotent (#40079)
af4dc3a9aa6 is described below

commit af4dc3a9aa64b782816b9bba08a651e7c37557eb
Author: Ville Brofeldt <[email protected]>
AuthorDate: Tue May 12 17:59:52 2026 -0700

    fix(re-encrypt): handle non-id PKs and make command idempotent (#40079)
---
 superset/cli/update.py                         |  22 ++-
 superset/utils/encrypt.py                      | 173 +++++++++++++++----
 tests/integration_tests/cli_tests.py           |  42 +++++
 tests/integration_tests/utils/encrypt_tests.py | 219 ++++++++++++++++++++++++-
 4 files changed, 409 insertions(+), 47 deletions(-)

diff --git a/superset/cli/update.py b/superset/cli/update.py
index c162bb1e56e..e35e394c325 100755
--- a/superset/cli/update.py
+++ b/superset/cli/update.py
@@ -115,15 +115,19 @@ def re_encrypt_secrets(previous_secret_key: Optional[str] 
= None) -> None:
         "PREVIOUS_SECRET_KEY"
     )
     if previous_secret_key is None:
-        click.secho("A previous secret key must be provided", err=True)
-        sys.exit(1)
-    secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
-    try:
-        secrets_migrator.run()
-    except ValueError as exc:
         click.secho(
-            f"An error occurred, "
-            f"probably an invalid previous secret key was provided. 
Error:[{exc}]",
-            err=True,
+            "No previous secret key provided; nothing to re-encrypt.",
+            fg="yellow",
         )
+        return
+    secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
+    try:
+        stats = secrets_migrator.run()
+    except Exception as exc:  # pylint: disable=broad-except
+        click.secho(f"Re-encryption failed: {exc}", err=True)
         sys.exit(1)
+    click.secho(
+        f"Re-encryption complete: {stats.re_encrypted} re-encrypted, "
+        f"{stats.skipped} skipped, {stats.null} null, {stats.failed} failed.",
+        fg="green",
+    )
diff --git a/superset/utils/encrypt.py b/superset/utils/encrypt.py
index bf1c5b159c4..963e72f9858 100644
--- a/superset/utils/encrypt.py
+++ b/superset/utils/encrypt.py
@@ -16,11 +16,12 @@
 # under the License.
 import logging
 from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from typing import Any, Optional
 
 from flask import Flask
 from flask_babel import lazy_gettext as _
-from sqlalchemy import text, TypeDecorator
+from sqlalchemy import Table, text, TypeDecorator
 from sqlalchemy.engine import Connection, Dialect, Row
 from sqlalchemy_utils import EncryptedType as SqlaEncryptedType
 
@@ -33,6 +34,16 @@ ENC_ADAPTER_TAG_ATTR_NAME = 
"__created_by_enc_field_adapter__"
 logger = logging.getLogger(__name__)
 
 
+@dataclass
+class ReEncryptStats:
+    """Per-value outcome counts for a SecretsMigrator.run() invocation."""
+
+    re_encrypted: int = 0
+    skipped: int = 0
+    null: int = 0
+    failed: int = 0
+
+
 class AbstractEncryptedFieldAdapter(ABC):  # pylint: 
disable=too-few-public-methods
     @abstractmethod
     def create(
@@ -97,11 +108,13 @@ class SecretsMigrator:
         self._previous_secret_key = previous_secret_key
         self._dialect: Dialect = db.engine.url.get_dialect()
 
-    def discover_encrypted_fields(self) -> dict[str, dict[str, EncryptedType]]:
+    def discover_encrypted_fields(
+        self,
+    ) -> dict[str, tuple[Table, dict[str, EncryptedType]]]:
         """
         Iterates over ORM-mapped tables, looking for EncryptedType columns
         along the way. Builds up a dict of
-        table_name -> dict of col_name: enc type instance
+        table_name -> (Table, dict of col_name: enc type instance)
 
         Superset's ORM models inherit from Flask-AppBuilder's declarative base
         (`flask_appbuilder.Model`), whose MetaData is distinct from
@@ -109,13 +122,18 @@ class SecretsMigrator:
         regardless of which base a model uses. FAB's metadata takes precedence
         when a table name appears in both registries.
 
-        :return: mapping of table name to a dict of {column name: 
EncryptedType}
+        The Table object is returned alongside the encrypted columns so callers
+        can introspect the schema (notably the primary key) without assuming a
+        conventional `id` column — some tables (e.g. `semantic_layers`) use a
+        `uuid` primary key instead.
+
+        :return: mapping of table name to (Table, {column name: EncryptedType})
         """
         from flask_appbuilder import (  # pylint: 
disable=import-outside-toplevel
             Model as FABModel,
         )
 
-        meta_info: dict[str, Any] = {}
+        meta_info: dict[str, tuple[Table, dict[str, EncryptedType]]] = {}
 
         tables: dict[str, Any] = dict(FABModel.metadata.tables)
         for table_name, table in self._db.metadata.tables.items():
@@ -124,9 +142,9 @@ class SecretsMigrator:
         for table_name, table in tables.items():
             for col_name, col in table.columns.items():
                 if isinstance(col.type, EncryptedType):
-                    cols = meta_info.get(table_name, {})
+                    _, cols = meta_info.get(table_name, (table, {}))
                     cols[col_name] = col.type
-                    meta_info[table_name] = cols
+                    meta_info[table_name] = (table, cols)
 
         return meta_info
 
@@ -151,9 +169,13 @@ class SecretsMigrator:
 
     @staticmethod
     def _select_columns_from_table(
-        conn: Connection, column_names: list[str], table_name: str
+        conn: Connection,
+        pk_columns: list[str],
+        column_names: list[str],
+        table_name: str,
     ) -> Row:
-        return conn.execute(f"SELECT id, {','.join(column_names)} FROM 
{table_name}")  # noqa: S608
+        cols = ",".join(pk_columns + column_names)
+        return conn.execute(f"SELECT {cols} FROM {table_name}")  # noqa: S608
 
     def _re_encrypt_row(
         self,
@@ -161,62 +183,143 @@ class SecretsMigrator:
         row: Row,
         table_name: str,
         columns: dict[str, EncryptedType],
+        pk_columns: list[str],
+        stats: ReEncryptStats,
     ) -> None:
         """
         Re encrypts all columns in a Row
+
+        Re-encryption is idempotent per column: we first ask whether the
+        current key can already decrypt the value, and skip if so. Only if
+        the current key fails do we fall back to decrypting with the
+        previous key and re-encrypting. Checking the current key first
+        keeps ``run()`` idempotent regardless of what ``previous_secret_key``
+        the caller supplies — even re-running with the same (unchanged)
+        ``SECRET_KEY`` will not rewrite rows.
+
+        NULL values are never encrypted, so they are reported separately
+        (neither re-encrypted nor "skipped because already current").
+
+        Per-column outcomes are accumulated onto ``stats`` so the caller can
+        report a summary. Columns whose ciphertext is unreadable under both
+        keys are counted as failures and logged; the exception is not
+        propagated, so processing continues. The caller is responsible for
+        raising once all rows have been scanned.
+
+        If no columns need re-encryption, no UPDATE is issued.
+
         :param row: Current row to reencrypt
         :param columns: Meta info from columns
+        :param pk_columns: Primary key column names used to target the row
+        :param stats: Mutable counters updated per column
         """
         re_encrypted_columns = {}
 
         for column_name, encrypted_type in columns.items():
+            raw_value = self._read_bytes(column_name, row[column_name])
+
+            # NULL values aren't encrypted; there is nothing to migrate.
+            if raw_value is None:
+                stats.null += 1
+                continue
+
+            # Fast path: if the current key can already read the value,
+            # leave it untouched. A failure here simply means we need to try
+            # the previous key below — not a condition worth logging.
+            try:
+                encrypted_type.process_result_value(raw_value, self._dialect)
+            except Exception:  # noqa: BLE001, S110  # pylint: 
disable=broad-except
+                pass
+            else:
+                stats.skipped += 1
+                continue
+
+            # Current key cannot decrypt — try the previous key.
             previous_encrypted_type = EncryptedType(
                 type_in=encrypted_type.underlying_type, 
key=self._previous_secret_key
             )
             try:
                 unencrypted_value = 
previous_encrypted_type.process_result_value(
-                    self._read_bytes(column_name, row[column_name]), 
self._dialect
+                    raw_value, self._dialect
                 )
-            except ValueError as ex:
-                # Failed to unencrypt
-                try:
-                    encrypted_type.process_result_value(
-                        self._read_bytes(column_name, row[column_name]), 
self._dialect
-                    )
-                    logger.info(
-                        "Current secret is able to decrypt value on column 
[%s.%s],"
-                        " nothing to do",
-                        table_name,
-                        column_name,
-                    )
-                    return
-                except Exception:
-                    raise Exception from ex  # pylint: 
disable=broad-exception-raised
+            except Exception as prev_ex:  # noqa: BLE001  # pylint: 
disable=broad-except
+                logger.error(
+                    "Column [%s.%s] cannot be decrypted under the previous"
+                    " or current secret key (%s: %s)",
+                    table_name,
+                    column_name,
+                    type(prev_ex).__name__,
+                    prev_ex,
+                )
+                stats.failed += 1
+                continue
 
             re_encrypted_columns[column_name] = 
encrypted_type.process_bind_param(
                 unencrypted_value,
                 self._dialect,
             )
+            stats.re_encrypted += 1
 
-        set_cols = ",".join(
-            [f"{name} = :{name}" for name in list(re_encrypted_columns.keys())]
-        )
-        logger.info("Processing table: %s", table_name)
+        if not re_encrypted_columns:
+            return
+
+        set_cols = ",".join(f"{name} = :{name}" for name in 
re_encrypted_columns)
+        where_clause = " AND ".join(f"{pk} = :_pk_{pk}" for pk in pk_columns)
+        pk_bind = {f"_pk_{pk}": row[pk] for pk in pk_columns}
         conn.execute(
-            text(f"UPDATE {table_name} SET {set_cols} WHERE id = :id"),  # 
noqa: S608
-            id=row["id"],
+            text(
+                f"UPDATE {table_name} SET {set_cols} WHERE {where_clause}"  # 
noqa: S608
+            ),
+            **pk_bind,
             **re_encrypted_columns,
         )
 
-    def run(self) -> None:
+    def run(self) -> ReEncryptStats:
+        """
+        Re-encrypt every encrypted column in the ORM under the current
+        ``SECRET_KEY``.
+
+        Returns per-value counts of re-encrypted, skipped (already under the
+        current key), and failed (undecryptable) outcomes. If any failures
+        occurred the transaction is rolled back by raising after the
+        summary is logged, so partial re-encryption never commits.
+        """
         encrypted_meta_info = self.discover_encrypted_fields()
+        stats = ReEncryptStats()
 
         with self._db.engine.begin() as conn:
             logger.info("Collecting info for re encryption")
-            for table_name, columns in encrypted_meta_info.items():
+            for table_name, (table, columns) in encrypted_meta_info.items():
+                pk_columns = [c.name for c in table.primary_key.columns]
+                if not pk_columns:
+                    logger.warning(
+                        "Skipping %s: no primary key, cannot target rows for 
update",
+                        table_name,
+                    )
+                    continue
                 column_names = list(columns.keys())
-                rows = self._select_columns_from_table(conn, column_names, 
table_name)
+                rows = self._select_columns_from_table(
+                    conn, pk_columns, column_names, table_name
+                )
 
                 for row in rows:
-                    self._re_encrypt_row(conn, row, table_name, columns)
+                    self._re_encrypt_row(
+                        conn, row, table_name, columns, pk_columns, stats
+                    )
+
+            logger.info(
+                "Re-encryption summary: %d re-encrypted, %d skipped,"
+                " %d null, %d failed",
+                stats.re_encrypted,
+                stats.skipped,
+                stats.null,
+                stats.failed,
+            )
+            if stats.failed:
+                raise Exception(  # pylint: disable=broad-exception-raised
+                    f"Re-encryption failed for {stats.failed} value(s); "
+                    "transaction rolled back"
+                )
+
         logger.info("All tables processed")
+        return stats
diff --git a/tests/integration_tests/cli_tests.py 
b/tests/integration_tests/cli_tests.py
index 328e41e147f..c41e88decc3 100644
--- a/tests/integration_tests/cli_tests.py
+++ b/tests/integration_tests/cli_tests.py
@@ -28,6 +28,7 @@ from freezegun import freeze_time
 
 import superset.cli.importexport
 import superset.cli.thumbnails
+import superset.cli.update
 from superset import db
 from superset.models.dashboard import Dashboard
 from tests.integration_tests.fixtures.birth_names_dashboard import (
@@ -322,3 +323,44 @@ def test_compute_thumbnails(thumbnail_mock, app_context, 
fs):
 
     thumbnail_mock.assert_called_with(None, dashboard.id, force=False)
     assert response.exit_code == 0
+
+
+def test_re_encrypt_secrets_without_previous_key_is_noop(app_context):
+    """
+    When neither --previous_secret_key nor config.PREVIOUS_SECRET_KEY is set,
+    the command should exit cleanly (0) rather than error out, so that
+    scheduled re-encryption runs don't start failing after a successful
+    rotation is complete.
+    """
+    current_app.config.pop("PREVIOUS_SECRET_KEY", None)
+    runner = current_app.test_cli_runner()
+    with mock.patch.object(superset.cli.update.SecretsMigrator, "run") as 
run_mock:
+        response = runner.invoke(superset.cli.update.re_encrypt_secrets, [])
+
+    assert response.exit_code == 0
+    assert "nothing to re-encrypt" in response.output.lower()
+    run_mock.assert_not_called()
+
+
+def test_re_encrypt_secrets_failure_exits_nonzero(app_context):
+    """
+    When re-encryption fails for any field, SecretsMigrator.run raises to
+    trigger rollback. The CLI must surface that as a non-zero exit with a
+    clear error message — not as an uncaught exception.
+    """
+    runner = current_app.test_cli_runner()
+    with mock.patch.object(
+        superset.cli.update.SecretsMigrator,
+        "run",
+        side_effect=Exception("Re-encryption failed for 2 value(s)"),
+    ):
+        response = runner.invoke(
+            superset.cli.update.re_encrypt_secrets,
+            ["--previous_secret_key", "old-key"],
+        )
+
+    assert response.exit_code == 1
+    assert "Re-encryption failed" in response.output
+    # The failure path must be handled by the CLI, not leaked as an
+    # uncaught exception.
+    assert response.exception is None or isinstance(response.exception, 
SystemExit)
diff --git a/tests/integration_tests/utils/encrypt_tests.py 
b/tests/integration_tests/utils/encrypt_tests.py
index e791411ca4e..5c88d43ecef 100644
--- a/tests/integration_tests/utils/encrypt_tests.py
+++ b/tests/integration_tests/utils/encrypt_tests.py
@@ -24,6 +24,7 @@ from sqlalchemy_utils.types.encrypted.encrypted_type import 
StringEncryptedType
 from superset.extensions import encrypted_field_factory
 from superset.utils.encrypt import (
     AbstractEncryptedFieldAdapter,
+    ReEncryptStats,
     SecretsMigrator,
     SQLAlchemyUtilsAdapter,
 )
@@ -79,7 +80,7 @@ class EncryptedFieldTest(SupersetTestCase):
 
         migrator = SecretsMigrator("")
         encrypted_fields = migrator.discover_encrypted_fields()
-        for table_name, cols in encrypted_fields.items():
+        for table_name, (_table, cols) in encrypted_fields.items():
             for col_name, field in cols.items():
                 if not 
encrypted_field_factory.created_by_enc_field_factory(field):
                     self.fail(
@@ -101,8 +102,33 @@ class EncryptedFieldTest(SupersetTestCase):
             "dbs table not found in encrypted fields — "
             "discover_encrypted_fields may be using the wrong MetaData 
instance"
         )
-        dbs_cols = set(encrypted_fields["dbs"].keys())
-        assert {"password", "encrypted_extra", 
"server_cert"}.issubset(dbs_cols)
+        _table, dbs_cols = encrypted_fields["dbs"]
+        assert {"password", "encrypted_extra", "server_cert"}.issubset(
+            set(dbs_cols.keys())
+        )
+
+    def test_discover_encrypted_fields_returns_table_with_non_id_pk(self):
+        """
+        Ensure discover_encrypted_fields surfaces the Table object alongside
+        encrypted columns, and that the PK introspection works for tables
+        whose primary key is not a conventional integer `id` column
+        (e.g. `semantic_layers` uses `uuid` as its PK).
+        """
+        # Import triggers FAB metadata registration for the semantic_layers 
table.
+        from superset.semantic_layers.models import SemanticLayer  # noqa: F401
+
+        migrator = SecretsMigrator("")
+        encrypted_fields = migrator.discover_encrypted_fields()
+        assert "semantic_layers" in encrypted_fields, (
+            "semantic_layers table not found — it has an encrypted 
`configuration` "
+            "column and should be discovered"
+        )
+        table, cols = encrypted_fields["semantic_layers"]
+        assert "configuration" in cols
+        pk_columns = [c.name for c in table.primary_key.columns]
+        assert pk_columns == ["uuid"], (
+            f"Expected semantic_layers PK to be ['uuid'], got {pk_columns}"
+        )
 
     def test_lazy_key_resolution(self):
         """
@@ -175,3 +201,190 @@ class EncryptedFieldTest(SupersetTestCase):
 
         # Restore original key
         self.app.config["SECRET_KEY"] = key_a
+
+    def test_re_encrypt_row_uses_pk_columns(self):
+        """
+        Verify SecretsMigrator builds UPDATE statements targeting the table's
+        actual primary key columns rather than a hardcoded `id` column.
+        Regression guard for tables like `semantic_layers` whose PK is `uuid`.
+        """
+        from unittest.mock import MagicMock
+
+        from sqlalchemy.engine import make_url
+
+        dialect = make_url("sqlite://").get_dialect()
+        previous_key = "PREVIOUS_KEY_FOR_PK_COLUMN_TEST"
+        migrator = SecretsMigrator(previous_key)
+        migrator._dialect = dialect  # noqa: SLF001
+
+        # Encrypt under the previous key so the current-key decrypt fails
+        # and the re-encrypt path (which issues the UPDATE) is exercised.
+        previous_field = EncryptedType(type_in=String(1024), key=previous_key)
+        ciphertext = previous_field.process_bind_param("hunter2", dialect)
+
+        current_field = encrypted_field_factory.create(String(1024))
+        conn = MagicMock()
+        row = {"uuid": b"\x00" * 16, "configuration": ciphertext}
+        stats = ReEncryptStats()
+
+        migrator._re_encrypt_row(  # noqa: SLF001
+            conn,
+            row,
+            "semantic_layers",
+            {"configuration": current_field},
+            ["uuid"],
+            stats,
+        )
+
+        assert conn.execute.call_count == 1
+        stmt = str(conn.execute.call_args.args[0])
+        assert "WHERE uuid = :_pk_uuid" in stmt
+        kwargs = conn.execute.call_args.kwargs
+        assert kwargs["_pk_uuid"] == row["uuid"]
+        assert "configuration" in kwargs
+        assert stats == ReEncryptStats(re_encrypted=1, skipped=0, failed=0)
+
+    def test_re_encrypt_row_is_idempotent(self):
+        """
+        Re-running re-encryption on a row that is already encrypted under the
+        current key must be a no-op: no UPDATE is issued, no error is raised,
+        and the outcome is counted as skipped.
+        """
+        from unittest.mock import MagicMock
+
+        from sqlalchemy.engine import make_url
+
+        dialect = make_url("sqlite://").get_dialect()
+        current_key = self.app.config["SECRET_KEY"]
+        migrator = SecretsMigrator("WRONG_PREVIOUS_KEY_abcdef")
+        migrator._dialect = dialect  # noqa: SLF001
+
+        field = encrypted_field_factory.create(String(1024))
+        ciphertext = field.process_bind_param("hunter2", dialect)
+        assert field.process_result_value(ciphertext, dialect) == "hunter2"
+
+        conn = MagicMock()
+        row = {"uuid": b"\x00" * 16, "configuration": ciphertext}
+        stats = ReEncryptStats()
+
+        migrator._re_encrypt_row(  # noqa: SLF001
+            conn,
+            row,
+            "semantic_layers",
+            {"configuration": field},
+            ["uuid"],
+            stats,
+        )
+
+        assert conn.execute.call_count == 0, (
+            "Row already readable under current key should not trigger UPDATE"
+        )
+        assert stats == ReEncryptStats(re_encrypted=0, skipped=1, failed=0)
+        # Current key must still decrypt the original ciphertext — nothing
+        # was mutated.
+        self.app.config["SECRET_KEY"] = current_key
+        assert field.process_result_value(ciphertext, dialect) == "hunter2"
+
+    def test_re_encrypt_row_idempotent_when_previous_key_also_decrypts(self):
+        """
+        When the supplied previous_secret_key can also decrypt the value
+        (e.g. re-running after a successful rotation while still passing
+        the original secret, or mistakenly passing the current secret as
+        the previous one), the row must still be skipped. Idempotency is
+        anchored on whether the current key can already read the data,
+        not on whether the previous key fails to decrypt.
+        """
+        from unittest.mock import MagicMock
+
+        from sqlalchemy.engine import make_url
+
+        dialect = make_url("sqlite://").get_dialect()
+        # Previous key == current key — this is the "re-run with no actual
+        # rotation" scenario.
+        migrator = SecretsMigrator(self.app.config["SECRET_KEY"])
+        migrator._dialect = dialect  # noqa: SLF001
+
+        field = encrypted_field_factory.create(String(1024))
+        ciphertext = field.process_bind_param("hunter2", dialect)
+
+        conn = MagicMock()
+        row = {"uuid": b"\x00" * 16, "configuration": ciphertext}
+        stats = ReEncryptStats()
+
+        migrator._re_encrypt_row(  # noqa: SLF001
+            conn,
+            row,
+            "semantic_layers",
+            {"configuration": field},
+            ["uuid"],
+            stats,
+        )
+
+        assert conn.execute.call_count == 0, (
+            "Idempotency must hold even when previous_secret_key can also "
+            "decrypt the value"
+        )
+        assert stats == ReEncryptStats(re_encrypted=0, skipped=1, failed=0)
+
+    def test_re_encrypt_row_counts_failures_without_raising(self):
+        """
+        Per-column failures are accumulated onto the stats counter so the
+        caller can emit a summary covering every row. The row method itself
+        must not raise — run() decides whether to abort based on the totals.
+        """
+        from unittest.mock import MagicMock
+
+        from sqlalchemy.engine import make_url
+
+        dialect = make_url("sqlite://").get_dialect()
+        migrator = SecretsMigrator("WRONG_PREVIOUS_KEY_abcdef")
+        migrator._dialect = dialect  # noqa: SLF001
+
+        field = encrypted_field_factory.create(String(1024))
+        conn = MagicMock()
+        row = {"uuid": b"\x00" * 16, "configuration": b"not-valid-ciphertext"}
+        stats = ReEncryptStats()
+
+        migrator._re_encrypt_row(  # noqa: SLF001
+            conn,
+            row,
+            "semantic_layers",
+            {"configuration": field},
+            ["uuid"],
+            stats,
+        )
+
+        assert conn.execute.call_count == 0
+        assert stats == ReEncryptStats(re_encrypted=0, skipped=0, failed=1)
+
+    def test_re_encrypt_row_counts_nulls_separately(self):
+        """
+        NULL column values are not encrypted and therefore have nothing to
+        migrate. They must be counted as ``null`` (not ``skipped``) and
+        must not trigger an UPDATE, regardless of which key is supplied as
+        the previous secret.
+        """
+        from unittest.mock import MagicMock
+
+        from sqlalchemy.engine import make_url
+
+        dialect = make_url("sqlite://").get_dialect()
+        migrator = SecretsMigrator("WRONG_PREVIOUS_KEY_abcdef")
+        migrator._dialect = dialect  # noqa: SLF001
+
+        field = encrypted_field_factory.create(String(1024))
+        conn = MagicMock()
+        row = {"uuid": b"\x00" * 16, "configuration": None}
+        stats = ReEncryptStats()
+
+        migrator._re_encrypt_row(  # noqa: SLF001
+            conn,
+            row,
+            "semantic_layers",
+            {"configuration": field},
+            ["uuid"],
+            stats,
+        )
+
+        assert conn.execute.call_count == 0
+        assert stats == ReEncryptStats(re_encrypted=0, skipped=0, null=1, 
failed=0)

Reply via email to