diff --git a/sat/plugins/plugin_xep_0424.py b/sat/plugins/plugin_xep_0424.py
new file mode 100644
index 00000000..1ab18def
--- /dev/null
+++ b/sat/plugins/plugin_xep_0424.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from typing import Dict, Any
+import time
+from copy import deepcopy
+
+from twisted.words.protocols.jabber import xmlstream, jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco
+from zope.interface import implementer
+
+from sat.core.constants import Const as C
+from sat.core.i18n import _, D_
+from sat.core import exceptions
+from sat.core.core_types import SatXMPPEntity
+from sat.core.log import getLogger
+from sat.memory.sqla_mapping import History
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+ C.PI_NAME: "Message Retraction",
+ C.PI_IMPORT_NAME: "XEP-0424",
+ C.PI_TYPE: "XEP",
+ C.PI_MODES: C.PLUG_MODE_BOTH,
+ C.PI_PROTOCOLS: ["XEP-0334", "XEP-0424", "XEP-0428"],
+ C.PI_DEPENDENCIES: ["XEP-0422"],
+ C.PI_MAIN: "XEP_0424",
+ C.PI_HANDLER: "yes",
+ C.PI_DESCRIPTION: _("""Implementation Message Retraction"""),
+}
+
+NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:0"
+
+CATEGORY = "Privacy"
+NAME = "retract_history"
+LABEL = D_("Keep History of Retracted Messages")
+PARAMS = """
+
+
+
+
+
+
+
+ """.format(
+ category_name=CATEGORY, name=NAME, label=_(LABEL)
+)
+
+
+class XEP_0424(object):
+
+ def __init__(self, host):
+ log.info(_("XEP-0424 (Message Retraction) plugin initialization"))
+ self.host = host
+ host.memory.updateParams(PARAMS)
+ self._h = host.plugins["XEP-0334"]
+ self._f = host.plugins["XEP-0422"]
+ host.registerNamespace("message-retract", NS_MESSAGE_RETRACT)
+ host.trigger.add("messageReceived", self._messageReceivedTrigger, 100)
+ host.bridge.addMethod(
+ "messRetract",
+ ".plugin",
+ in_sign="ss",
+ out_sign="",
+ method=self._retract,
+ async_=True,
+ )
+
+ def getHandler(self, __):
+ return XEP_0424_handler()
+
+ def _retract(self, message_id: str, profile: str) -> None:
+ client = self.host.getClient(profile)
+ return defer.ensureDeferred(
+ self.retract(client, message_id)
+ )
+
+ def retractByOriginId(
+ self,
+ client: SatXMPPEntity,
+ dest_jid: jid.JID,
+ origin_id: str
+ ) -> None:
+ """Send a message retraction using origin-id
+
+ [retract] should be prefered: internal ID should be used as it is independant of
+ XEPs changes. However, in some case messages may not be stored in database
+ (notably for some components), and then this method can be used
+ @param origin_id: origin-id as specified in XEP-0359
+ """
+ message_elt = domish.Element((None, "message"))
+ message_elt["from"] = client.jid.full()
+ message_elt["to"] = dest_jid.full()
+ apply_to_elt = self._f.applyToElt(message_elt, origin_id)
+ apply_to_elt.addElement((NS_MESSAGE_RETRACT, "retract"))
+ self.host.plugins["XEP-0428"].addFallbackElt(
+ message_elt,
+ "[A message retraction has been requested, but your client doesn't support "
+ "it]"
+ )
+ self._h.addHintElements(message_elt, [self._h.HINT_STORE])
+ client.send(message_elt)
+
+ async def retractByHistory(
+ self,
+ client: SatXMPPEntity,
+ history: History
+ ) -> None:
+ """Send a message retraction using History instance
+
+ This method is to use instead of [retract] when the history instance is already
+ retrieved. Note that the instance must have messages and subjets loaded
+ @param history: history instance of the message to retract
+ """
+ try:
+ origin_id = history.origin_id
+ except KeyError:
+ raise exceptions.FeatureNotFound(
+ f"message to retract doesn't have the necessary origin-id, the sending "
+ "client is probably not supporting message retraction."
+ )
+ else:
+ self.retractByOriginId(client, history.dest_jid, origin_id)
+ await self.retractDBHistory(client, history)
+
+ async def retract(
+ self,
+ client: SatXMPPEntity,
+ message_id: str,
+ ) -> None:
+ """Send a message retraction request
+
+ @param message_id: ID of the message
+ This ID is the Libervia internal ID of the message. It will be retrieve from
+ database to find the ID used by XMPP (i.e. XEP-0359's "origin ID"). If the
+ message is not found in database, an exception will be raised
+ """
+ if not message_id:
+ raise ValueError("message_id can't be empty")
+ history = await self.host.memory.storage.get(
+ client, History, History.uid, message_id,
+ joined_loads=[History.messages, History.subjects]
+ )
+ if history is None:
+ raise exceptions.NotFound(
+ f"message to retract not found in database ({message_id})"
+ )
+ await self.retractByHistory(client, history)
+
+ async def retractDBHistory(self, client, history: History) -> None:
+ """Mark an history instance in database as retracted
+
+ @param history: history instance
+ "messages" and "subjects" must be loaded too
+ """
+ # FIXME: should be keep history? This is useful to check why a message has been
+ # retracted, but if may be bad if the user think it's really deleted
+ # we assign a new object to be sure to trigger an update
+ history.extra = deepcopy(history.extra) if history.extra else {}
+ history.extra["retracted"] = True
+ keep_history = self.host.memory.getParamA(
+ NAME, CATEGORY, profile_key=client.profile
+ )
+ old_version: Dict[str, Any] = {
+ "timestamp": time.time()
+ }
+ if keep_history:
+ old_version.update({
+ "messages": [m.serialise() for m in history.messages],
+ "subjects": [s.serialise() for s in history.subjects]
+ })
+
+ history.extra.setdefault("old_versions", []).append(old_version)
+ await self.host.memory.storage.delete(
+ history.messages + history.subjects,
+ session_add=[history]
+ )
+
+ async def _messageReceivedTrigger(
+ self,
+ client: SatXMPPEntity,
+ message_elt: domish.Element,
+ post_treat: defer.Deferred
+ ) -> bool:
+ fastened_elts = await self._f.getFastenedElts(client, message_elt)
+ if fastened_elts is None:
+ return True
+ for elt in fastened_elts.elements:
+ if elt.name == "retract" and elt.uri == NS_MESSAGE_RETRACT:
+ if fastened_elts.history is not None:
+ source_jid = fastened_elts.history.source_jid
+ from_jid = jid.JID(message_elt["from"])
+ if source_jid.userhostJID() != from_jid.userhostJID():
+ log.warning(
+ f"Received message retraction from {from_jid.full()}, but "
+ f"the message to retract is from {source_jid.full()}. This "
+ f"maybe a hack attempt.\n{message_elt.toXml()}"
+ )
+ return False
+ break
+ else:
+ return True
+ if not await self.host.trigger.asyncPoint(
+ "XEP-0424_retractReceived", client, message_elt, elt, fastened_elts
+ ):
+ return False
+ if fastened_elts.history is None:
+ # we check history after the trigger because we may be in a component which
+ # doesn't store messages in database.
+ log.warning(
+ f"No message found with given origin-id: {message_elt.toXml()}"
+ )
+ return False
+ log.info(f"[{client.profile}] retracting message {fastened_elts.id!r}")
+ await self.retractDBHistory(client, fastened_elts.history)
+ # TODO: send bridge signal
+
+ return False
+
+
+@implementer(disco.IDisco)
+class XEP_0424_handler(xmlstream.XMPPHandler):
+
+ def getDiscoInfo(self, __, target, nodeIdentifier=""):
+ return [disco.DiscoFeature(NS_MESSAGE_RETRACT)]
+
+ def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+ return []