From 3cb2d91c1a56fa0c5e64beee1b7055fce85a263c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miguel=20=C3=81ngel=20Ortu=C3=B1o?= <ortuman@gmail.com>
Date: Sun, 12 Jun 2022 12:51:43 +0200
Subject: [PATCH] module: added support for xep-0313
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Miguel Ángel Ortuño <ortuman@gmail.com>
---
 README.md                             |   6 +-
 config/example.config.yaml            |   5 +
 go.mod                                |   4 +-
 go.sum                                |  12 +-
 helm/sql/postgres.up.psql             |  22 ++
 helm/values.yaml                      |   4 +
 pkg/admin/pb/users.pb.go              |   2 +-
 pkg/c2s/in.go                         |  82 ++--
 pkg/c2s/pb/resourceinfo.pb.go         |   2 +-
 pkg/c2s/router.go                     |   9 +-
 pkg/cluster/pb/cluster.pb.go          |   2 +-
 pkg/hook/c2s.go                       |   8 +-
 pkg/hook/hooks.go                     |  10 +-
 pkg/hook/s2s.go                       |  14 +-
 pkg/jackal/config.go                  |   5 +
 pkg/jackal/jackal.go                  |   3 +-
 pkg/jackal/modules.go                 |   9 +-
 pkg/model/archive/archive.pb.go       | 514 +++++++++++++++++++++++++
 pkg/model/archive/codec.go            |  37 ++
 pkg/model/blocklist/blocklist.pb.go   |   2 +-
 pkg/model/caps/caps.pb.go             |   2 +-
 pkg/model/last/last.pb.go             |   2 +-
 pkg/model/roster/roster.pb.go         |   2 +-
 pkg/model/user/user.pb.go             |   2 +-
 pkg/module/offline/interface.go       |   6 +
 pkg/module/offline/offline.go         |  57 +--
 pkg/module/offline/offline_test.go    |  32 +-
 pkg/module/roster/roster.go           |  19 +-
 pkg/module/roster/roster_test.go      |   8 +-
 pkg/module/xep0004/field.go           |  26 ++
 pkg/module/xep0004/field_test.go      |  13 +
 pkg/module/xep0004/fields.go          |   2 +-
 pkg/module/xep0004/validate.go        | 116 ++++++
 pkg/module/xep0004/validate_test.go   |  40 ++
 pkg/module/xep0049/private.go         |   6 +-
 pkg/module/xep0059/rsm.go             | 241 ++++++++++++
 pkg/module/xep0059/rsm_test.go        | 158 ++++++++
 pkg/module/xep0191/blocklist.go       |  10 +-
 pkg/module/xep0191/blocklist_test.go  |   4 +-
 pkg/module/xep0198/stream.go          |  16 +-
 pkg/module/xep0199/ping.go            |   3 +-
 pkg/module/xep0199/ping_test.go       |   4 +-
 pkg/module/xep0280/carbons.go         |  60 ++-
 pkg/module/xep0280/carbons_test.go    |   8 +-
 pkg/module/xep0313/interface.go       |  51 +++
 pkg/module/xep0313/mam.go             | 522 ++++++++++++++++++++++++++
 pkg/module/xep0313/mam_test.go        | 477 +++++++++++++++++++++++
 pkg/router/router.go                  |   2 +-
 pkg/s2s/in.go                         |  31 +-
 pkg/storage/boltdb/archive.go         | 274 ++++++++++++++
 pkg/storage/boltdb/archive_test.go    | 292 ++++++++++++++
 pkg/storage/boltdb/offline_test.go    |  14 +-
 pkg/storage/boltdb/op.go              |   3 +-
 pkg/storage/boltdb/repository.go      |   1 +
 pkg/storage/boltdb/repository_test.go |  36 ++
 pkg/storage/boltdb/tx.go              |   2 +
 pkg/storage/boltdb/util_test.go       |  16 +-
 pkg/storage/cached/cached.go          |   2 +
 pkg/storage/cached/tx.go              |   2 +
 pkg/storage/measured/archive.go       |  63 ++++
 pkg/storage/measured/archive_test.go  |  99 +++++
 pkg/storage/measured/measured.go      |   2 +
 pkg/storage/measured/tx.go            |   2 +
 pkg/storage/pgsql/archive.go          | 214 +++++++++++
 pkg/storage/pgsql/archive_test.go     | 217 +++++++++++
 pkg/storage/pgsql/repository.go       |   2 +
 pkg/storage/pgsql/tx.go               |   2 +
 pkg/storage/repository/archive.go     |  39 ++
 pkg/storage/repository/repository.go  |   1 +
 pkg/util/xmpp/xmpp.go                 |  47 ++-
 pkg/util/xmpp/xmpp_test.go            |  52 +++
 proto/model/v1/archive.proto          |  85 +++++
 scripts/genproto.sh                   |   1 +
 sql/postgres.up.psql                  |  22 ++
 74 files changed, 3958 insertions(+), 204 deletions(-)
 create mode 100644 pkg/model/archive/archive.pb.go
 create mode 100644 pkg/model/archive/codec.go
 create mode 100644 pkg/module/xep0004/validate.go
 create mode 100644 pkg/module/xep0004/validate_test.go
 create mode 100644 pkg/module/xep0059/rsm.go
 create mode 100644 pkg/module/xep0059/rsm_test.go
 create mode 100644 pkg/module/xep0313/interface.go
 create mode 100644 pkg/module/xep0313/mam.go
 create mode 100644 pkg/module/xep0313/mam_test.go
 create mode 100644 pkg/storage/boltdb/archive.go
 create mode 100644 pkg/storage/boltdb/archive_test.go
 create mode 100644 pkg/storage/boltdb/repository_test.go
 create mode 100644 pkg/storage/measured/archive.go
 create mode 100644 pkg/storage/measured/archive_test.go
 create mode 100644 pkg/storage/pgsql/archive.go
 create mode 100644 pkg/storage/pgsql/archive_test.go
 create mode 100644 pkg/storage/repository/archive.go
 create mode 100644 proto/model/v1/archive.proto

diff --git a/README.md b/README.md
index 7dc4f772c..7975c386f 100644
--- a/README.md
+++ b/README.md
@@ -201,9 +201,11 @@ Once up and running, don't forget to [register one or more users](#creating-jack
 - [XEP-0030: Service Discovery](https://xmpp.org/extensions/xep-0030.html) *2.5rc3*
 - [XEP-0049: Private XML Storage](https://xmpp.org/extensions/xep-0049.html) *1.2*
 - [XEP-0054: vcard-temp](https://xmpp.org/extensions/xep-0054.html) *1.2*
+- [XEP-0059: Result Set Management](https://xmpp.org/extensions/xep-0059.html) *1.0*
 - [XEP-0092: Software Version](https://xmpp.org/extensions/xep-0092.html) *1.1*
 - [XEP-0114: Jabber Component Protocol](https://xmpp.org/extensions/xep-0114.html) *1.6*  
-- [XEP-0115: Entity Capabilities](https://xmpp.org/extensions/xep-0115.html) *1.5.2*  
+- [XEP-0115: Entity Capabilities](https://xmpp.org/extensions/xep-0115.html) *1.5.2*
+- [XEP-0122: Data Forms Validation](https://xmpp.org/extensions/xep-0122.html) *1.0.2*
 - [XEP-0138: Stream Compression](https://xmpp.org/extensions/xep-0138.html) *2.0*
 - [XEP-0160: Best Practices for Handling Offline Messages](https://xmpp.org/extensions/xep-0160.html) *1.0.1*
 - [XEP-0190: Best Practice for Closing Idle Streams](https://xmpp.org/extensions/xep-0190.html) *1.1*
@@ -214,6 +216,8 @@ Once up and running, don't forget to [register one or more users](#creating-jack
 - [XEP-0220: Server Dialback](https://xmpp.org/extensions/xep-0220.html) *1.1.1*
 - [XEP-0237: Roster Versioning](https://xmpp.org/extensions/xep-0237.html) *1.3*
 - [XEP-0280: Message Carbons](https://xmpp.org/extensions/xep-0280.html) *0.13.3*
+- [XEP-0297: Stanza Forwarding](https://xmpp.org/extensions/xep-0297.html) *1.0*
+- [XEP-0313: Message Archive Management](https://xmpp.org/extensions/xep-0313.html) *1.0.1*
 - [XEP-0368: SRV records for XMPP over TLS](https://xmpp.org/extensions/xep-0368.html) *1.1.0*
 
 ## Join and Contribute
diff --git a/config/example.config.yaml b/config/example.config.yaml
index 34e9f18b9..d7ae0dce4 100644
--- a/config/example.config.yaml
+++ b/config/example.config.yaml
@@ -128,6 +128,7 @@ modules:
 #    - ping        # XEP-0199: XMPP Ping
 #    - time        # XEP-0202: Entity Time
 #    - carbons     # XEP-0280: Message Carbons
+#    - mam         # XEP-0313: Message Archive Management
 #
 #  version:
 #    show_os: true
@@ -140,6 +141,10 @@ modules:
 #    interval: 3m
 #    send_pings: true
 #    timeout_action: kill
+#
+#  mam:
+#    queue_size: 1500
+#
 
 components:
   secret: a-super-secret-key
diff --git a/go.mod b/go.mod
index 46d649ada..1363d1b55 100644
--- a/go.mod
+++ b/go.mod
@@ -17,11 +17,12 @@ require (
 	github.com/google/uuid v1.1.2
 	github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
 	github.com/jackal-xmpp/runqueue/v2 v2.0.0
-	github.com/jackal-xmpp/stravaganza v1.2.3
+	github.com/jackal-xmpp/stravaganza v1.2.4
 	github.com/kkyr/fig v0.2.0
 	github.com/lib/pq v1.8.0
 	github.com/mattn/go-sqlite3 v1.14.5 // indirect
 	github.com/prometheus/client_golang v1.11.0
+	github.com/samber/lo v1.25.0
 	github.com/spf13/cobra v1.1.3
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.7.1
@@ -61,6 +62,7 @@ require (
 	go.uber.org/atomic v1.7.0 // indirect
 	go.uber.org/multierr v1.6.0 // indirect
 	go.uber.org/zap v1.17.0 // indirect
+	golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
 	golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect
 	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
 	golang.org/x/text v0.3.7 // indirect
diff --git a/go.sum b/go.sum
index 5c5106ade..1a40ef4a4 100644
--- a/go.sum
+++ b/go.sum
@@ -225,8 +225,8 @@ github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62
 github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
 github.com/jackal-xmpp/runqueue/v2 v2.0.0 h1:QfvOfL6zF5yK1LN5TKabpj+VBuELMwtR8Xpkz0CrjoI=
 github.com/jackal-xmpp/runqueue/v2 v2.0.0/go.mod h1:tXZARVqBMGeV8BTc/qDPg0qXILTUWmER7wlYbN9Xcac=
-github.com/jackal-xmpp/stravaganza v1.2.3 h1:fxxyvtkj94CHYfooy7YsFRue7jFtJaMg3BozfWlzSOY=
-github.com/jackal-xmpp/stravaganza v1.2.3/go.mod h1:oesgQpMM0I5gnJM80NsEfSspzDDCArQex+oA0/swCWU=
+github.com/jackal-xmpp/stravaganza v1.2.4 h1:xz3L2lNEPezOn43az4W4omK1at9tSuR4BDaWOSKo6aE=
+github.com/jackal-xmpp/stravaganza v1.2.4/go.mod h1:oesgQpMM0I5gnJM80NsEfSspzDDCArQex+oA0/swCWU=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -306,6 +306,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW
 github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
 github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@@ -363,6 +364,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/samber/lo v1.25.0 h1:H8F6cB0RotRdgcRCivTByAQePaYhGMdOTJIj2QFS2I0=
+github.com/samber/lo v1.25.0/go.mod h1:2I7tgIv8Q1SG2xEIkRq0F2i2zgxVpnyPOP0d3Gj2r+A=
 github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
@@ -397,6 +400,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
 github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
@@ -450,6 +454,8 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
 golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
 golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -642,8 +648,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
diff --git a/helm/sql/postgres.up.psql b/helm/sql/postgres.up.psql
index 3be5cf8da..39d0ad1c1 100644
--- a/helm/sql/postgres.up.psql
+++ b/helm/sql/postgres.up.psql
@@ -170,3 +170,25 @@ CREATE TABLE IF NOT EXISTS vcards (
 );
 
 SELECT enable_updated_at('vcards');
+
+-- archives
+
+CREATE TABLE IF NOT EXISTS archives (
+    serial     SERIAL PRIMARY KEY,
+    archive_id VARCHAR(1023),
+    id         VARCHAR(255) NOT NULL,
+    "from"     TEXT NOT NULL,
+    from_bare  TEXT NOT NULL,
+    "to"       TEXT NOT NULL,
+    to_bare    TEXT NOT NULL,
+    message    BYTEA NOT NULL,
+    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS i_archives_archive_id ON archives(archive_id);
+CREATE INDEX IF NOT EXISTS i_archives_id ON archives(id);
+CREATE INDEX IF NOT EXISTS i_archives_to ON archives("to");
+CREATE INDEX IF NOT EXISTS i_archives_to_bare ON archives(to_bare);
+CREATE INDEX IF NOT EXISTS i_archives_from ON archives("from");
+CREATE INDEX IF NOT EXISTS i_archives_from_bare ON archives(from_bare);
+CREATE INDEX IF NOT EXISTS i_archives_created_at ON archives(created_at);
diff --git a/helm/values.yaml b/helm/values.yaml
index 8f9e21369..d28dc9c05 100644
--- a/helm/values.yaml
+++ b/helm/values.yaml
@@ -125,6 +125,7 @@ jackal:
         - ping        # XEP-0199: XMPP Ping
         - time        # XEP-0202: Entity Time
         - carbons     # XEP-0280: Message Carbons
+        - mam         # XEP-0313: Message Archive Management
 
       version:
         show_os: true
@@ -138,6 +139,9 @@ jackal:
         send_pings: true
         timeout_action: kill
 
+      mam:
+        queue_size: 1500
+
     components:
     #  listeners:
     #    - port: 5275
diff --git a/pkg/admin/pb/users.pb.go b/pkg/admin/pb/users.pb.go
index 2dbd15c7f..9ade76daa 100644
--- a/pkg/admin/pb/users.pb.go
+++ b/pkg/admin/pb/users.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/admin/v1/users.proto
 
 package pb
diff --git a/pkg/c2s/in.go b/pkg/c2s/in.go
index 11f5d30a6..042ac8bf4 100644
--- a/pkg/c2s/in.go
+++ b/pkg/c2s/in.go
@@ -404,13 +404,13 @@ func (s *inC2S) connTimeout() {
 
 func (s *inC2S) handleElement(ctx context.Context, elem stravaganza.Element) error {
 	// run received element hook
-	hInf := &hook.C2SStreamInfo{
+	hi := &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  elem,
 	}
-	halted, err := s.runHook(ctx, hook.C2SStreamElementReceived, hInf)
+	halted, err := s.runHook(ctx, hook.C2SStreamElementReceived, hi)
 	if halted {
 		return nil
 	}
@@ -421,15 +421,15 @@ func (s *inC2S) handleElement(ctx context.Context, elem stravaganza.Element) err
 	t0 := time.Now()
 	switch s.getState() {
 	case inConnecting:
-		err = s.handleConnecting(ctx, hInf.Element)
+		err = s.handleConnecting(ctx, hi.Element)
 	case inConnected:
-		err = s.handleConnected(ctx, hInf.Element)
+		err = s.handleConnected(ctx, hi.Element)
 	case inAuthenticating:
-		err = s.handleAuthenticating(ctx, hInf.Element)
+		err = s.handleAuthenticating(ctx, hi.Element)
 	case inAuthenticated:
-		err = s.handleAuthenticated(ctx, hInf.Element)
+		err = s.handleAuthenticated(ctx, hi.Element)
 	case inBinded:
-		err = s.handleBinded(ctx, hInf.Element)
+		err = s.handleBinded(ctx, hi.Element)
 	}
 	reportIncomingRequest(
 		elem.Name(),
@@ -553,15 +553,18 @@ func (s *inC2S) processStanza(ctx context.Context, stanza stravaganza.Stanza) er
 
 func (s *inC2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	// run iq received hook
-	_, err := s.runHook(ctx, hook.C2SStreamIQReceived, &hook.C2SStreamInfo{
+	hi := &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  iq,
-	})
+	}
+	_, err := s.runHook(ctx, hook.C2SStreamIQReceived, hi)
 	if err != nil {
 		return err
 	}
+	iq = hi.Element.(*stravaganza.IQ)
+
 	if iq.IsSet() && iq.ChildNamespace("session", sessionNamespace) != nil {
 		if !s.flags.isSessionStarted() {
 			s.flags.setSessionStarted()
@@ -576,24 +579,24 @@ func (s *inC2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 		return s.mods.ProcessIQ(ctx, iq)
 	}
 	// run will route iq hook
-	hInf := &hook.C2SStreamInfo{
+	hi = &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  iq,
 	}
-	halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hInf)
+	halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hi)
 	if halted {
 		return nil
 	}
 	if err != nil {
 		return err
 	}
-	outIQ, ok := hInf.Element.(*stravaganza.IQ)
+	iq, ok := hi.Element.(*stravaganza.IQ)
 	if !ok {
 		return nil
 	}
-	targets, err := s.router.Route(ctx, outIQ)
+	targets, err := s.router.Route(ctx, iq)
 	switch err {
 	case router.ErrResourceNotFound:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, iq).Element())
@@ -604,8 +607,8 @@ func (s *inC2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	case router.ErrRemoteServerTimeout:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.RemoteServerTimeout, iq).Element())
 
-	case nil:
-		_, err := s.runHook(ctx, hook.C2SStreamIQRouted, &hook.C2SStreamInfo{
+	case nil, router.ErrUserNotAvailable:
+		_, err = s.runHook(ctx, hook.C2SStreamIQRouted, &hook.C2SStreamInfo{
 			ID:       s.ID().String(),
 			JID:      s.JID(),
 			Presence: s.Presence(),
@@ -619,38 +622,40 @@ func (s *inC2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 
 func (s *inC2S) processPresence(ctx context.Context, presence *stravaganza.Presence) error {
 	// run presence received hook
-	_, err := s.runHook(ctx, hook.C2SStreamPresenceReceived, &hook.C2SStreamInfo{
+	hi := &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  presence,
-	})
+	}
+	_, err := s.runHook(ctx, hook.C2SStreamPresenceReceived, hi)
 	if err != nil {
 		return err
 	}
+	presence = hi.Element.(*stravaganza.Presence)
 
 	if presence.ToJID().IsFullWithUser() {
 		// run will route presence hook
-		hInf := &hook.C2SStreamInfo{
+		hi = &hook.C2SStreamInfo{
 			ID:       s.ID().String(),
 			JID:      s.JID(),
 			Presence: s.Presence(),
 			Element:  presence,
 		}
-		halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hInf)
+		halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hi)
 		if halted {
 			return nil
 		}
 		if err != nil {
 			return err
 		}
-		outPr, ok := hInf.Element.(*stravaganza.Presence)
+		presence, ok := hi.Element.(*stravaganza.Presence)
 		if !ok {
 			return nil
 		}
-		targets, err := s.router.Route(ctx, outPr)
+		targets, err := s.router.Route(ctx, presence)
 		switch err {
-		case nil:
+		case nil, router.ErrUserNotAvailable:
 			_, err = s.runHook(ctx, hook.C2SStreamPresenceRouted, &hook.C2SStreamInfo{
 				ID:      s.ID().String(),
 				JID:     s.JID(),
@@ -672,37 +677,38 @@ func (s *inC2S) processPresence(ctx context.Context, presence *stravaganza.Prese
 
 func (s *inC2S) processMessage(ctx context.Context, message *stravaganza.Message) error {
 	// run message received hook
-	_, err := s.runHook(ctx, hook.C2SStreamMessageReceived, &hook.C2SStreamInfo{
+	hi := &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  message,
-	})
+	}
+	_, err := s.runHook(ctx, hook.C2SStreamMessageReceived, hi)
 	if err != nil {
 		return err
 	}
-	msg := message
+	msg := hi.Element.(*stravaganza.Message)
 
 sendMsg:
-	// run will route Message hook
-	hInf := &hook.C2SStreamInfo{
+	// run will route message hook
+	hi = &hook.C2SStreamInfo{
 		ID:       s.ID().String(),
 		JID:      s.JID(),
 		Presence: s.Presence(),
 		Element:  msg,
 	}
-	halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hInf)
+	halted, err := s.runHook(ctx, hook.C2SStreamWillRouteElement, hi)
 	if halted {
 		return nil
 	}
 	if err != nil {
 		return err
 	}
-	outMsg, ok := hInf.Element.(*stravaganza.Message)
+	msg, ok := hi.Element.(*stravaganza.Message)
 	if !ok {
 		return nil
 	}
-	targets, err := s.router.Route(ctx, outMsg)
+	targets, err := s.router.Route(ctx, msg)
 	switch err {
 	case router.ErrResourceNotFound:
 		// treat the stanza as if it were addressed to <node@domain>
@@ -721,18 +727,21 @@ sendMsg:
 	case router.ErrRemoteServerTimeout:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.RemoteServerTimeout, message).Element())
 
-	case router.ErrUserNotAvailable:
-		return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, message).Element())
-
-	case nil:
-		_, err = s.runHook(ctx, hook.C2SStreamMessageRouted, &hook.C2SStreamInfo{
+	case nil, router.ErrUserNotAvailable:
+		halted, hErr := s.runHook(ctx, hook.C2SStreamMessageRouted, &hook.C2SStreamInfo{
 			ID:       s.ID().String(),
 			JID:      s.JID(),
 			Presence: s.Presence(),
 			Targets:  targets,
 			Element:  msg,
 		})
-		return err
+		if halted {
+			return nil
+		}
+		if errors.Is(err, router.ErrUserNotAvailable) {
+			return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, message).Element())
+		}
+		return hErr
 
 	default:
 		return err
@@ -1108,6 +1117,7 @@ func (s *inC2S) close(ctx context.Context, disconnectErr error) error {
 	halted, err := s.runHook(ctx, hook.C2SStreamDisconnected, &hook.C2SStreamInfo{
 		ID:              s.ID().String(),
 		JID:             s.JID(),
+		Presence:        s.Presence(),
 		DisconnectError: disconnectErr,
 	})
 	if halted {
diff --git a/pkg/c2s/pb/resourceinfo.pb.go b/pkg/c2s/pb/resourceinfo.pb.go
index 9b7c2685d..ff94ee504 100644
--- a/pkg/c2s/pb/resourceinfo.pb.go
+++ b/pkg/c2s/pb/resourceinfo.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/c2s/v1/resourceinfo.proto
 
 package pb
diff --git a/pkg/c2s/router.go b/pkg/c2s/router.go
index 30285008c..a5205aeb9 100644
--- a/pkg/c2s/router.go
+++ b/pkg/c2s/router.go
@@ -16,6 +16,7 @@ package c2s
 
 import (
 	"context"
+	"fmt"
 	"sort"
 
 	kitlog "github.com/go-kit/log"
@@ -115,8 +116,12 @@ func (r *c2sRouter) Unregister(stm stream.C2S) error {
 	return nil
 }
 
-func (r *c2sRouter) LocalStream(username, resource string) stream.C2S {
-	return r.local.Stream(username, resource)
+func (r *c2sRouter) LocalStream(username, resource string) (stream.C2S, error) {
+	stm := r.local.Stream(username, resource)
+	if stm == nil {
+		return nil, fmt.Errorf("c2s: local stream not found: %s/%s", username, resource)
+	}
+	return stm, nil
 }
 
 func (r *c2sRouter) Start(ctx context.Context) error {
diff --git a/pkg/cluster/pb/cluster.pb.go b/pkg/cluster/pb/cluster.pb.go
index d5a18a03e..50e337584 100644
--- a/pkg/cluster/pb/cluster.pb.go
+++ b/pkg/cluster/pb/cluster.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/cluster/v1/cluster.proto
 
 package pb
diff --git a/pkg/hook/c2s.go b/pkg/hook/c2s.go
index 744361b1f..16d763b40 100644
--- a/pkg/hook/c2s.go
+++ b/pkg/hook/c2s.go
@@ -47,16 +47,16 @@ const (
 	// C2SStreamWillRouteElement hook runs when an XMPP element is about to be routed over a C2S stream.
 	C2SStreamWillRouteElement = "c2s.stream.will_route_element"
 
-	// C2SStreamIQRouted hook runs when an iq stanza is successfully routed to one ore more C2S streams.
+	// C2SStreamIQRouted hook runs when an iq stanza is successfully routed to zero or more C2S streams.
 	C2SStreamIQRouted = "c2s.stream.iq_routed"
 
-	// C2SStreamPresenceRouted hook runs when a presence stanza is successfully routed to one ore more C2S streams.
+	// C2SStreamPresenceRouted hook runs when a presence stanza is successfully routed to zero or more C2S streams.
 	C2SStreamPresenceRouted = "c2s.stream.presence_routed"
 
-	// C2SStreamMessageRouted hook runs when a message stanza is successfully routed to one ore more C2S streams.
+	// C2SStreamMessageRouted hook runs when a message stanza is successfully routed to zero or more C2S streams.
 	C2SStreamMessageRouted = "c2s.stream.message_routed"
 
-	// C2SStreamElementSent hook runs when a XMPP element is sent over a C2S stream.
+	// C2SStreamElementSent hook runs when an XMPP element is sent over a C2S stream.
 	C2SStreamElementSent = "c2s.stream.element_sent"
 )
 
diff --git a/pkg/hook/hooks.go b/pkg/hook/hooks.go
index f61542dee..e7ea5600d 100644
--- a/pkg/hook/hooks.go
+++ b/pkg/hook/hooks.go
@@ -28,13 +28,19 @@ type Priority int32
 
 const (
 	// LowestPriority defines lowest hook execution priority.
-	LowestPriority = Priority(math.MinInt32 + 100)
+	LowestPriority = Priority(math.MinInt32)
+
+	// LowPriority defines low hook execution priority.
+	LowPriority = Priority(math.MinInt32 + 1000)
 
 	// DefaultPriority defines default hook execution priority.
 	DefaultPriority = Priority(0)
 
+	// HighPriority defines high hook execution priority.
+	HighPriority = Priority(math.MaxInt32 - 1000)
+
 	// HighestPriority defines highest hook execution priority.
-	HighestPriority = Priority(math.MaxInt32 - 100)
+	HighestPriority = Priority(math.MaxInt32)
 )
 
 // Handler defines a generic hook handler function.
diff --git a/pkg/hook/s2s.go b/pkg/hook/s2s.go
index 0eab5ed2f..6b13c4d6a 100644
--- a/pkg/hook/s2s.go
+++ b/pkg/hook/s2s.go
@@ -16,6 +16,7 @@ package hook
 
 import (
 	"github.com/jackal-xmpp/stravaganza"
+	"github.com/jackal-xmpp/stravaganza/jid"
 )
 
 const (
@@ -25,7 +26,7 @@ const (
 	// S2SOutStreamDisconnected hook runs when an outgoing S2S connection is unregistered.
 	S2SOutStreamDisconnected = "s2s.out.stream.disconnected"
 
-	// S2SOutStreamElementSent hook runs whenever a XMPP element is sent over an outgoing S2S stream.
+	// S2SOutStreamElementSent hook runs whenever an XMPP element is sent over an outgoing S2S stream.
 	S2SOutStreamElementSent = "s2s.out.stream.element_sent"
 
 	// S2SInStreamRegistered hook runs when an incoming S2S connection is registered.
@@ -34,7 +35,7 @@ const (
 	// S2SInStreamUnregistered hook runs when an incoming S2S connection is unregistered.
 	S2SInStreamUnregistered = "s2s.in.stream.unregistered"
 
-	// S2SInStreamElementReceived hook runs when a XMPP element is received over an incoming S2S stream.
+	// S2SInStreamElementReceived hook runs when an XMPP element is received over an incoming S2S stream.
 	S2SInStreamElementReceived = "s2s.in.stream.stanza_received"
 
 	// S2SInStreamIQReceived hook runs when an iq stanza is received over an incoming S2S stream.
@@ -49,13 +50,13 @@ const (
 	// S2SInStreamWillRouteElement hook runs when an XMPP element is about to be routed on an incoming S2S stream.
 	S2SInStreamWillRouteElement = "s2s.in.stream.will_route_element"
 
-	// S2SInStreamIQRouted hook runs when an iq stanza is successfully routed to one ore more S2S streams.
+	// S2SInStreamIQRouted hook runs when an iq stanza is successfully routed to zero or more C2S streams.
 	S2SInStreamIQRouted = "s2s.in.stream.iq_routed"
 
-	// S2SInStreamPresenceRouted hook runs when a presence stanza is successfully routed to one ore more S2S streams.
+	// S2SInStreamPresenceRouted hook runs when a presence stanza is successfully routed to zero or more C2S streams.
 	S2SInStreamPresenceRouted = "s2s.in.stream.presence_routed"
 
-	// S2SInStreamMessageRouted hook runs when a message stanza is successfully routed to one ore more S2S streams.
+	// S2SInStreamMessageRouted hook runs when a message stanza is successfully routed to zero or more C2S streams.
 	S2SInStreamMessageRouted = "s2s.in.stream.message_routed"
 )
 
@@ -70,6 +71,9 @@ type S2SStreamInfo struct {
 	// Target is the S2S target domain.
 	Target string
 
+	// Targets contains all JIDs to which event stanza was routed.
+	Targets []jid.JID
+
 	// Element is the event associated XMPP element.
 	Element stravaganza.Element
 }
diff --git a/pkg/jackal/config.go b/pkg/jackal/config.go
index ae4c95173..95452d224 100644
--- a/pkg/jackal/config.go
+++ b/pkg/jackal/config.go
@@ -17,6 +17,8 @@ package jackal
 import (
 	"path/filepath"
 
+	"github.com/ortuman/jackal/pkg/module/xep0313"
+
 	"github.com/kkyr/fig"
 	adminserver "github.com/ortuman/jackal/pkg/admin/server"
 	"github.com/ortuman/jackal/pkg/auth/pepper"
@@ -95,6 +97,9 @@ type ModulesConfig struct {
 
 	// XEP-0199: XMPP Ping
 	Ping xep0199.Config `fig:"ping"`
+
+	// XEP-0313: Message Archive Management
+	Mam xep0313.Config `fig:"mam"`
 }
 
 // Config defines jackal application configuration.
diff --git a/pkg/jackal/jackal.go b/pkg/jackal/jackal.go
index 6ccdac89a..1eeb6dfff 100644
--- a/pkg/jackal/jackal.go
+++ b/pkg/jackal/jackal.go
@@ -26,8 +26,6 @@ import (
 	"syscall"
 	"time"
 
-	streamqueue "github.com/ortuman/jackal/pkg/module/xep0198/queue"
-
 	kitlog "github.com/go-kit/log"
 	"github.com/go-kit/log/level"
 	grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
@@ -47,6 +45,7 @@ import (
 	"github.com/ortuman/jackal/pkg/host"
 	"github.com/ortuman/jackal/pkg/log"
 	"github.com/ortuman/jackal/pkg/module"
+	streamqueue "github.com/ortuman/jackal/pkg/module/xep0198/queue"
 	"github.com/ortuman/jackal/pkg/router"
 	"github.com/ortuman/jackal/pkg/s2s"
 	"github.com/ortuman/jackal/pkg/shaper"
diff --git a/pkg/jackal/modules.go b/pkg/jackal/modules.go
index d03b87fff..d6b11bcda 100644
--- a/pkg/jackal/modules.go
+++ b/pkg/jackal/modules.go
@@ -30,6 +30,7 @@ import (
 	"github.com/ortuman/jackal/pkg/module/xep0199"
 	"github.com/ortuman/jackal/pkg/module/xep0202"
 	"github.com/ortuman/jackal/pkg/module/xep0280"
+	"github.com/ortuman/jackal/pkg/module/xep0313"
 )
 
 var defaultModules = []string{
@@ -45,6 +46,7 @@ var defaultModules = []string{
 	xep0198.ModuleName,
 	xep0199.ModuleName,
 	xep0280.ModuleName,
+	xep0313.ModuleName,
 }
 
 var modFns = map[string]func(a *Jackal, cfg *ModulesConfig) module.Module{
@@ -56,7 +58,7 @@ var modFns = map[string]func(a *Jackal, cfg *ModulesConfig) module.Module{
 	// Offline
 	// (https://xmpp.org/extensions/xep-0160.html)
 	offline.ModuleName: func(j *Jackal, cfg *ModulesConfig) module.Module {
-		return offline.New(cfg.Offline, j.router, j.hosts, j.resMng, j.rep, j.hk, j.logger)
+		return offline.New(cfg.Offline, j.router, j.hosts, j.rep, j.hk, j.logger)
 	},
 	// XEP-0012: Last Activity
 	// (https://xmpp.org/extensions/xep-0012.html)
@@ -114,4 +116,9 @@ var modFns = map[string]func(a *Jackal, cfg *ModulesConfig) module.Module{
 	xep0280.ModuleName: func(j *Jackal, _ *ModulesConfig) module.Module {
 		return xep0280.New(j.router, j.hosts, j.resMng, j.hk, j.logger)
 	},
+	// XEP-0313: Message Archive Management
+	// (https://xmpp.org/extensions/xep-0313.html)
+	xep0313.ModuleName: func(j *Jackal, cfg *ModulesConfig) module.Module {
+		return xep0313.New(cfg.Mam, j.router, j.hosts, j.rep, j.hk, j.logger)
+	},
 }
diff --git a/pkg/model/archive/archive.pb.go b/pkg/model/archive/archive.pb.go
new file mode 100644
index 000000000..a9249431c
--- /dev/null
+++ b/pkg/model/archive/archive.pb.go
@@ -0,0 +1,514 @@
+// Copyright 2022 The jackal Authors
+//
+// 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.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.26.0
+// 	protoc        v3.21.5
+// source: proto/model/v1/archive.proto
+
+package archivemodel
+
+import (
+	stravaganza "github.com/jackal-xmpp/stravaganza"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Message represents an archive message entity.
+type Message struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// archived_id is the message archive identifier.
+	ArchiveId string `protobuf:"bytes,1,opt,name=archive_id,json=archiveId,proto3" json:"archive_id,omitempty"`
+	// id is the message archive unique identifier.
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+	// from_jid is the message from jid value.
+	FromJid string `protobuf:"bytes,3,opt,name=from_jid,json=fromJid,proto3" json:"from_jid,omitempty"`
+	// to_jid is the message from jid value.
+	ToJid string `protobuf:"bytes,4,opt,name=to_jid,json=toJid,proto3" json:"to_jid,omitempty"`
+	// message is the archived message.
+	Message *stravaganza.PBElement `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"`
+	// stamp is the timestamp in which the message was archived.
+	Stamp *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=stamp,proto3" json:"stamp,omitempty"`
+}
+
+func (x *Message) Reset() {
+	*x = Message{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_model_v1_archive_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Message) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Message) ProtoMessage() {}
+
+func (x *Message) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_model_v1_archive_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Message.ProtoReflect.Descriptor instead.
+func (*Message) Descriptor() ([]byte, []int) {
+	return file_proto_model_v1_archive_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Message) GetArchiveId() string {
+	if x != nil {
+		return x.ArchiveId
+	}
+	return ""
+}
+
+func (x *Message) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+func (x *Message) GetFromJid() string {
+	if x != nil {
+		return x.FromJid
+	}
+	return ""
+}
+
+func (x *Message) GetToJid() string {
+	if x != nil {
+		return x.ToJid
+	}
+	return ""
+}
+
+func (x *Message) GetMessage() *stravaganza.PBElement {
+	if x != nil {
+		return x.Message
+	}
+	return nil
+}
+
+func (x *Message) GetStamp() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Stamp
+	}
+	return nil
+}
+
+// Messages represents a set of archive messages.
+type Messages struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	ArchiveMessages []*Message `protobuf:"bytes,1,rep,name=archive_messages,json=archiveMessages,proto3" json:"archive_messages,omitempty"`
+}
+
+func (x *Messages) Reset() {
+	*x = Messages{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_model_v1_archive_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Messages) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Messages) ProtoMessage() {}
+
+func (x *Messages) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_model_v1_archive_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Messages.ProtoReflect.Descriptor instead.
+func (*Messages) Descriptor() ([]byte, []int) {
+	return file_proto_model_v1_archive_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Messages) GetArchiveMessages() []*Message {
+	if x != nil {
+		return x.ArchiveMessages
+	}
+	return nil
+}
+
+// Metadata represents an archive metadata information.
+type Metadata struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// start_timestamp is the identifier of the first archive message.
+	StartId string `protobuf:"bytes,1,opt,name=start_id,json=startId,proto3" json:"start_id,omitempty"`
+	// start_timestamp is the timestamp value of the first archive message.
+	StartTimestamp string `protobuf:"bytes,2,opt,name=start_timestamp,json=startTimestamp,proto3" json:"start_timestamp,omitempty"`
+	// end_id is the identifier of the last archive message.
+	EndId string `protobuf:"bytes,3,opt,name=end_id,json=endId,proto3" json:"end_id,omitempty"`
+	// end_timestamp is the timestamp value of the last archive message.
+	EndTimestamp string `protobuf:"bytes,4,opt,name=end_timestamp,json=endTimestamp,proto3" json:"end_timestamp,omitempty"`
+}
+
+func (x *Metadata) Reset() {
+	*x = Metadata{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_model_v1_archive_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Metadata) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Metadata) ProtoMessage() {}
+
+func (x *Metadata) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_model_v1_archive_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Metadata.ProtoReflect.Descriptor instead.
+func (*Metadata) Descriptor() ([]byte, []int) {
+	return file_proto_model_v1_archive_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *Metadata) GetStartId() string {
+	if x != nil {
+		return x.StartId
+	}
+	return ""
+}
+
+func (x *Metadata) GetStartTimestamp() string {
+	if x != nil {
+		return x.StartTimestamp
+	}
+	return ""
+}
+
+func (x *Metadata) GetEndId() string {
+	if x != nil {
+		return x.EndId
+	}
+	return ""
+}
+
+func (x *Metadata) GetEndTimestamp() string {
+	if x != nil {
+		return x.EndTimestamp
+	}
+	return ""
+}
+
+// Filters define a set of filters to be applied when fetching archive messages.
+type Filters struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// start is used to filter out messages before a certain date/time.
+	Start *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=start,proto3" json:"start,omitempty"`
+	// end is used to filter out messages after a certain date/time.
+	End *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=end,proto3" json:"end,omitempty"`
+	// with contains a JID against which to match messages.
+	With string `protobuf:"bytes,3,opt,name=with,proto3" json:"with,omitempty"`
+	// before_id is the id of the newest message user wants to fetch.
+	BeforeId string `protobuf:"bytes,4,opt,name=before_id,json=beforeId,proto3" json:"before_id,omitempty"`
+	// after_id is the id of the oldest message user wants to fetch.
+	AfterId string `protobuf:"bytes,5,opt,name=after_id,json=afterId,proto3" json:"after_id,omitempty"`
+	// ids contains one or more ids the user wants to fetch.
+	Ids []string `protobuf:"bytes,6,rep,name=ids,proto3" json:"ids,omitempty"`
+}
+
+func (x *Filters) Reset() {
+	*x = Filters{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_model_v1_archive_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Filters) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Filters) ProtoMessage() {}
+
+func (x *Filters) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_model_v1_archive_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Filters.ProtoReflect.Descriptor instead.
+func (*Filters) Descriptor() ([]byte, []int) {
+	return file_proto_model_v1_archive_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *Filters) GetStart() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Start
+	}
+	return nil
+}
+
+func (x *Filters) GetEnd() *timestamppb.Timestamp {
+	if x != nil {
+		return x.End
+	}
+	return nil
+}
+
+func (x *Filters) GetWith() string {
+	if x != nil {
+		return x.With
+	}
+	return ""
+}
+
+func (x *Filters) GetBeforeId() string {
+	if x != nil {
+		return x.BeforeId
+	}
+	return ""
+}
+
+func (x *Filters) GetAfterId() string {
+	if x != nil {
+		return x.AfterId
+	}
+	return ""
+}
+
+func (x *Filters) GetIds() []string {
+	if x != nil {
+		return x.Ids
+	}
+	return nil
+}
+
+var File_proto_model_v1_archive_proto protoreflect.FileDescriptor
+
+var file_proto_model_v1_archive_proto_rawDesc = []byte{
+	0x0a, 0x1c, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x76, 0x31,
+	0x2f, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10,
+	0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x2e, 0x76, 0x31,
+	0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x1a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6a, 0x61,
+	0x63, 0x6b, 0x61, 0x6c, 0x2d, 0x78, 0x6d, 0x70, 0x70, 0x2f, 0x73, 0x74, 0x72, 0x61, 0x76, 0x61,
+	0x67, 0x61, 0x6e, 0x7a, 0x61, 0x2f, 0x73, 0x74, 0x72, 0x61, 0x76, 0x61, 0x67, 0x61, 0x6e, 0x7a,
+	0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xce, 0x01, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73,
+	0x61, 0x67, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x5f, 0x69,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65,
+	0x49, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
+	0x69, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x6a, 0x69, 0x64, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x72, 0x6f, 0x6d, 0x4a, 0x69, 0x64, 0x12, 0x15, 0x0a,
+	0x06, 0x74, 0x6f, 0x5f, 0x6a, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74,
+	0x6f, 0x4a, 0x69, 0x64, 0x12, 0x30, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x73, 0x74, 0x72, 0x61, 0x76, 0x61, 0x67, 0x61,
+	0x6e, 0x7a, 0x61, 0x2e, 0x50, 0x42, 0x45, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x07, 0x6d,
+	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18,
+	0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+	0x70, 0x52, 0x05, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x50, 0x0a, 0x08, 0x4d, 0x65, 0x73, 0x73,
+	0x61, 0x67, 0x65, 0x73, 0x12, 0x44, 0x0a, 0x10, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x5f,
+	0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19,
+	0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x2e, 0x76,
+	0x31, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x0f, 0x61, 0x72, 0x63, 0x68, 0x69,
+	0x76, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x8a, 0x01, 0x0a, 0x08, 0x4d,
+	0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x74, 0x61, 0x72, 0x74,
+	0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x74, 0x61, 0x72, 0x74,
+	0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65,
+	0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x74, 0x61,
+	0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x65,
+	0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6e, 0x64,
+	0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x6e, 0x64, 0x54, 0x69,
+	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0xc7, 0x01, 0x0a, 0x07, 0x46, 0x69, 0x6c, 0x74,
+	0x65, 0x72, 0x73, 0x12, 0x30, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05,
+	0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03,
+	0x65, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x77, 0x69, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x04, 0x77, 0x69, 0x74, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x65, 0x66, 0x6f, 0x72,
+	0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x62, 0x65, 0x66, 0x6f,
+	0x72, 0x65, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64,
+	0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x66, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12,
+	0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x69, 0x64,
+	0x73, 0x42, 0x21, 0x5a, 0x1f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x61,
+	0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x2f, 0x3b, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x6d,
+	0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_proto_model_v1_archive_proto_rawDescOnce sync.Once
+	file_proto_model_v1_archive_proto_rawDescData = file_proto_model_v1_archive_proto_rawDesc
+)
+
+func file_proto_model_v1_archive_proto_rawDescGZIP() []byte {
+	file_proto_model_v1_archive_proto_rawDescOnce.Do(func() {
+		file_proto_model_v1_archive_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_model_v1_archive_proto_rawDescData)
+	})
+	return file_proto_model_v1_archive_proto_rawDescData
+}
+
+var file_proto_model_v1_archive_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_proto_model_v1_archive_proto_goTypes = []interface{}{
+	(*Message)(nil),               // 0: model.archive.v1.Message
+	(*Messages)(nil),              // 1: model.archive.v1.Messages
+	(*Metadata)(nil),              // 2: model.archive.v1.Metadata
+	(*Filters)(nil),               // 3: model.archive.v1.Filters
+	(*stravaganza.PBElement)(nil), // 4: stravaganza.PBElement
+	(*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp
+}
+var file_proto_model_v1_archive_proto_depIdxs = []int32{
+	4, // 0: model.archive.v1.Message.message:type_name -> stravaganza.PBElement
+	5, // 1: model.archive.v1.Message.stamp:type_name -> google.protobuf.Timestamp
+	0, // 2: model.archive.v1.Messages.archive_messages:type_name -> model.archive.v1.Message
+	5, // 3: model.archive.v1.Filters.start:type_name -> google.protobuf.Timestamp
+	5, // 4: model.archive.v1.Filters.end:type_name -> google.protobuf.Timestamp
+	5, // [5:5] is the sub-list for method output_type
+	5, // [5:5] is the sub-list for method input_type
+	5, // [5:5] is the sub-list for extension type_name
+	5, // [5:5] is the sub-list for extension extendee
+	0, // [0:5] is the sub-list for field type_name
+}
+
+func init() { file_proto_model_v1_archive_proto_init() }
+func file_proto_model_v1_archive_proto_init() {
+	if File_proto_model_v1_archive_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_proto_model_v1_archive_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Message); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_proto_model_v1_archive_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Messages); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_proto_model_v1_archive_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Metadata); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_proto_model_v1_archive_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Filters); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_proto_model_v1_archive_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_proto_model_v1_archive_proto_goTypes,
+		DependencyIndexes: file_proto_model_v1_archive_proto_depIdxs,
+		MessageInfos:      file_proto_model_v1_archive_proto_msgTypes,
+	}.Build()
+	File_proto_model_v1_archive_proto = out.File
+	file_proto_model_v1_archive_proto_rawDesc = nil
+	file_proto_model_v1_archive_proto_goTypes = nil
+	file_proto_model_v1_archive_proto_depIdxs = nil
+}
diff --git a/pkg/model/archive/codec.go b/pkg/model/archive/codec.go
new file mode 100644
index 000000000..e3d126426
--- /dev/null
+++ b/pkg/model/archive/codec.go
@@ -0,0 +1,37 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 archivemodel
+
+import "google.golang.org/protobuf/proto"
+
+// MarshalBinary satisfies encoding.BinaryMarshaler interface.
+func (x *Message) MarshalBinary() (data []byte, err error) {
+	return proto.Marshal(x)
+}
+
+// UnmarshalBinary satisfies encoding.BinaryUnmarshaler interface.
+func (x *Message) UnmarshalBinary(data []byte) error {
+	return proto.Unmarshal(data, x)
+}
+
+// MarshalBinary satisfies encoding.BinaryMarshaler interface.
+func (x *Messages) MarshalBinary() (data []byte, err error) {
+	return proto.Marshal(x)
+}
+
+// UnmarshalBinary satisfies encoding.BinaryUnmarshaler interface.
+func (x *Messages) UnmarshalBinary(data []byte) error {
+	return proto.Unmarshal(data, x)
+}
diff --git a/pkg/model/blocklist/blocklist.pb.go b/pkg/model/blocklist/blocklist.pb.go
index aa55dec13..06b254ddd 100644
--- a/pkg/model/blocklist/blocklist.pb.go
+++ b/pkg/model/blocklist/blocklist.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/model/v1/blocklist.proto
 
 package blocklistmodel
diff --git a/pkg/model/caps/caps.pb.go b/pkg/model/caps/caps.pb.go
index 30da67176..6a7ea0bb1 100644
--- a/pkg/model/caps/caps.pb.go
+++ b/pkg/model/caps/caps.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/model/v1/caps.proto
 
 package capsmodel
diff --git a/pkg/model/last/last.pb.go b/pkg/model/last/last.pb.go
index e21a61af8..ebe05bc21 100644
--- a/pkg/model/last/last.pb.go
+++ b/pkg/model/last/last.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/model/v1/last.proto
 
 package lastmodel
diff --git a/pkg/model/roster/roster.pb.go b/pkg/model/roster/roster.pb.go
index dbd15aa9b..6aa120fed 100644
--- a/pkg/model/roster/roster.pb.go
+++ b/pkg/model/roster/roster.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/model/v1/roster.proto
 
 package rostermodel
diff --git a/pkg/model/user/user.pb.go b/pkg/model/user/user.pb.go
index dfb888413..ba960ae07 100644
--- a/pkg/model/user/user.pb.go
+++ b/pkg/model/user/user.pb.go
@@ -15,7 +15,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.19.4
+// 	protoc        v3.21.5
 // source: proto/model/v1/user.proto
 
 package usermodel
diff --git a/pkg/module/offline/interface.go b/pkg/module/offline/interface.go
index 1b8962dae..388731e3a 100644
--- a/pkg/module/offline/interface.go
+++ b/pkg/module/offline/interface.go
@@ -17,6 +17,7 @@ package offline
 import (
 	"github.com/ortuman/jackal/pkg/cluster/resourcemanager"
 	"github.com/ortuman/jackal/pkg/router"
+	"github.com/ortuman/jackal/pkg/router/stream"
 	"github.com/ortuman/jackal/pkg/storage/repository"
 )
 
@@ -39,3 +40,8 @@ type hosts interface {
 type resourceManager interface {
 	resourcemanager.Manager
 }
+
+//go:generate moq -out stream.mock_test.go . c2sStream
+type c2sStream interface {
+	stream.C2S
+}
diff --git a/pkg/module/offline/offline.go b/pkg/module/offline/offline.go
index 49f018468..588896299 100644
--- a/pkg/module/offline/offline.go
+++ b/pkg/module/offline/offline.go
@@ -23,10 +23,12 @@ import (
 	"github.com/go-kit/log/level"
 	"github.com/jackal-xmpp/stravaganza"
 	stanzaerror "github.com/jackal-xmpp/stravaganza/errors/stanza"
-	"github.com/ortuman/jackal/pkg/cluster/resourcemanager"
+	"github.com/jackal-xmpp/stravaganza/jid"
 	"github.com/ortuman/jackal/pkg/hook"
 	"github.com/ortuman/jackal/pkg/host"
+	"github.com/ortuman/jackal/pkg/module/xep0313"
 	"github.com/ortuman/jackal/pkg/router"
+	"github.com/ortuman/jackal/pkg/router/stream"
 	"github.com/ortuman/jackal/pkg/storage/repository"
 	xmpputil "github.com/ortuman/jackal/pkg/util/xmpp"
 )
@@ -51,7 +53,6 @@ type Offline struct {
 	cfg    Config
 	hosts  hosts
 	router router.Router
-	resMng resourcemanager.Manager
 	rep    repository.Repository
 	hk     *hook.Hooks
 	logger kitlog.Logger
@@ -62,7 +63,6 @@ func New(
 	cfg Config,
 	router router.Router,
 	hosts *host.Hosts,
-	resMng resourcemanager.Manager,
 	rep repository.Repository,
 	hk *hook.Hooks,
 	logger kitlog.Logger,
@@ -71,7 +71,6 @@ func New(
 		cfg:    cfg,
 		router: router,
 		hosts:  hosts,
-		resMng: resMng,
 		rep:    rep,
 		hk:     hk,
 		logger: kitlog.With(logger, "module", ModuleName),
@@ -96,8 +95,8 @@ func (m *Offline) AccountFeatures(_ context.Context) ([]string, error) { return
 
 // Start starts offline module.
 func (m *Offline) Start(_ context.Context) error {
-	m.hk.AddHook(hook.C2SStreamWillRouteElement, m.onWillRouteElement, hook.LowestPriority)
-	m.hk.AddHook(hook.S2SInStreamWillRouteElement, m.onWillRouteElement, hook.LowestPriority)
+	m.hk.AddHook(hook.C2SStreamMessageRouted, m.onMessageRouted, hook.LowestPriority)
+	m.hk.AddHook(hook.S2SInStreamMessageRouted, m.onMessageRouted, hook.LowestPriority)
 
 	m.hk.AddHook(hook.C2SStreamPresenceReceived, m.onC2SPresenceRecv, hook.DefaultPriority)
 	m.hk.AddHook(hook.UserDeleted, m.onUserDeleted, hook.DefaultPriority)
@@ -108,8 +107,8 @@ func (m *Offline) Start(_ context.Context) error {
 
 // Stop stops offline module.
 func (m *Offline) Stop(_ context.Context) error {
-	m.hk.RemoveHook(hook.C2SStreamWillRouteElement, m.onWillRouteElement)
-	m.hk.RemoveHook(hook.S2SInStreamWillRouteElement, m.onWillRouteElement)
+	m.hk.RemoveHook(hook.C2SStreamMessageRouted, m.onMessageRouted)
+	m.hk.RemoveHook(hook.S2SInStreamMessageRouted, m.onMessageRouted)
 
 	m.hk.RemoveHook(hook.C2SStreamPresenceReceived, m.onC2SPresenceRecv)
 	m.hk.RemoveHook(hook.UserDeleted, m.onUserDeleted)
@@ -118,15 +117,23 @@ func (m *Offline) Stop(_ context.Context) error {
 	return nil
 }
 
-func (m *Offline) onWillRouteElement(execCtx *hook.ExecutionContext) error {
+func (m *Offline) onMessageRouted(execCtx *hook.ExecutionContext) error {
 	var elem stravaganza.Element
+	var targets []jid.JID
 
 	switch inf := execCtx.Info.(type) {
 	case *hook.C2SStreamInfo:
+		targets = inf.Targets
 		elem = inf.Element
 	case *hook.S2SStreamInfo:
+		targets = inf.Targets
 		elem = inf.Element
 	}
+	// message was successufully routed to one of the available resources
+	if len(targets) > 0 {
+		return nil
+	}
+
 	msg, ok := elem.(*stravaganza.Message)
 	if !ok || !isMessageArchievable(msg) {
 		return nil
@@ -135,17 +142,15 @@ func (m *Offline) onWillRouteElement(execCtx *hook.ExecutionContext) error {
 	if !m.hosts.IsLocalHost(toJID.Domain()) {
 		return nil
 	}
-	rss, err := m.resMng.GetResources(execCtx.Context, toJID.Node())
-	if err != nil {
-		return err
-	}
-	if len(rss) > 0 {
-		return nil
-	}
 	return m.archiveMessage(execCtx.Context, msg)
 }
 
 func (m *Offline) onC2SPresenceRecv(execCtx *hook.ExecutionContext) error {
+	stm := execCtx.Sender.(stream.C2S)
+	if xep0313.IsArchiveRequested(stm.Info()) {
+		// user has already queried the MAM archive.
+		return nil
+	}
 	inf := execCtx.Info.(*hook.C2SStreamInfo)
 
 	pr := inf.Element.(*stravaganza.Presence)
@@ -156,7 +161,7 @@ func (m *Offline) onC2SPresenceRecv(execCtx *hook.ExecutionContext) error {
 	if !pr.IsAvailable() || pr.Priority() < 0 {
 		return nil
 	}
-	return m.deliverOfflineMessages(execCtx.Context, toJID.Node())
+	return m.deliverOfflineMessages(execCtx.Context, stm)
 }
 
 func (m *Offline) onUserDeleted(execCtx *hook.ExecutionContext) error {
@@ -168,18 +173,20 @@ func (m *Offline) onUserDeleted(execCtx *hook.ExecutionContext) error {
 	if err := m.rep.Lock(ctx, lockID); err != nil {
 		return err
 	}
-	defer func() { _ = m.rep.Unlock(ctx, lockID) }()
+	defer m.releaseLock(ctx, lockID)
 
 	return m.rep.DeleteOfflineMessages(ctx, inf.Username)
 }
 
-func (m *Offline) deliverOfflineMessages(ctx context.Context, username string) error {
+func (m *Offline) deliverOfflineMessages(ctx context.Context, stm stream.C2S) error {
+	username := stm.Username()
+
 	lockID := offlineQueueLockID(username)
 
 	if err := m.rep.Lock(ctx, lockID); err != nil {
 		return err
 	}
-	defer func() { _ = m.rep.Unlock(ctx, lockID) }()
+	defer m.releaseLock(ctx, lockID)
 
 	ms, err := m.rep.FetchOfflineMessages(ctx, username)
 	if err != nil {
@@ -194,7 +201,7 @@ func (m *Offline) deliverOfflineMessages(ctx context.Context, username string) e
 	}
 	// route offline messages
 	for _, msg := range ms {
-		_, _ = m.router.Route(ctx, msg)
+		stm.SendElement(msg)
 	}
 	level.Info(m.logger).Log("msg", "delivered offline messages", "queue_size", len(ms), "username", username)
 
@@ -210,7 +217,7 @@ func (m *Offline) archiveMessage(ctx context.Context, msg *stravaganza.Message)
 	if err := m.rep.Lock(ctx, lockID); err != nil {
 		return err
 	}
-	defer func() { _ = m.rep.Unlock(ctx, lockID) }()
+	defer m.releaseLock(ctx, lockID)
 
 	qSize, err := m.rep.CountOfflineMessages(ctx, username)
 	if err != nil {
@@ -243,6 +250,12 @@ func (m *Offline) archiveMessage(ctx context.Context, msg *stravaganza.Message)
 	return hook.ErrStopped // already handled
 }
 
+func (m *Offline) releaseLock(ctx context.Context, lockID string) {
+	if err := m.rep.Unlock(ctx, lockID); err != nil {
+		level.Warn(m.logger).Log("msg", "failed to release lock", "err", err)
+	}
+}
+
 func isMessageArchievable(msg *stravaganza.Message) bool {
 	if msg.ChildNamespace("no-store", hintsNamespace) != nil {
 		return false
diff --git a/pkg/module/offline/offline_test.go b/pkg/module/offline/offline_test.go
index b59985572..b71e80596 100644
--- a/pkg/module/offline/offline_test.go
+++ b/pkg/module/offline/offline_test.go
@@ -43,15 +43,10 @@ func TestOffline_ArchiveOfflineMessage(t *testing.T) {
 	hostsMock := &hostsMock{}
 	hostsMock.IsLocalHostFunc = func(h string) bool { return h == "jackal.im" }
 
-	resManagerMock := &resourceManagerMock{}
-	resManagerMock.GetResourcesFunc = func(ctx context.Context, username string) ([]c2smodel.ResourceDesc, error) {
-		return nil, nil
-	}
 	hk := hook.NewHooks()
 	m := &Offline{
 		cfg:    Config{QueueSize: 100},
 		hosts:  hostsMock,
-		resMng: resManagerMock,
 		rep:    repMock,
 		hk:     hk,
 		logger: kitlog.NewNopLogger(),
@@ -70,7 +65,7 @@ func TestOffline_ArchiveOfflineMessage(t *testing.T) {
 	_ = m.Start(context.Background())
 	defer func() { _ = m.Stop(context.Background()) }()
 
-	_, _ = hk.Run(hook.C2SStreamWillRouteElement, &hook.ExecutionContext{
+	_, _ = hk.Run(hook.C2SStreamMessageRouted, &hook.ExecutionContext{
 		Info: &hook.C2SStreamInfo{
 			Element: msg,
 		},
@@ -114,7 +109,6 @@ func TestOffline_ArchiveOfflineMessageQueueFull(t *testing.T) {
 		cfg:    Config{QueueSize: 100},
 		router: routerMock,
 		hosts:  hostsMock,
-		resMng: resManagerMock,
 		rep:    repMock,
 		hk:     hk,
 		logger: kitlog.NewNopLogger(),
@@ -133,7 +127,7 @@ func TestOffline_ArchiveOfflineMessageQueueFull(t *testing.T) {
 	_ = m.Start(context.Background())
 	defer func() { _ = m.Stop(context.Background()) }()
 
-	halted, err := hk.Run(hook.C2SStreamWillRouteElement, &hook.ExecutionContext{
+	halted, err := hk.Run(hook.C2SStreamMessageRouted, &hook.ExecutionContext{
 		Info: &hook.C2SStreamInfo{
 			Element: msg,
 		},
@@ -154,11 +148,6 @@ func TestOffline_DeliverOfflineMessages(t *testing.T) {
 	// given
 	routerMock := &routerMock{}
 
-	output := bytes.NewBuffer(nil)
-	routerMock.RouteFunc = func(ctx context.Context, stanza stravaganza.Stanza) ([]jid.JID, error) {
-		_ = stanza.ToXML(output, true)
-		return nil, nil
-	}
 	hostsMock := &hostsMock{}
 	hostsMock.IsLocalHostFunc = func(h string) bool { return h == "jackal.im" }
 
@@ -186,6 +175,22 @@ func TestOffline_DeliverOfflineMessages(t *testing.T) {
 		return nil
 	}
 
+	stmMock := &c2sStreamMock{}
+	stmMock.UsernameFunc = func() string {
+		return "ortuman"
+	}
+	stmMock.InfoFunc = func() c2smodel.Info {
+		return c2smodel.NewInfoMap()
+	}
+
+	output := bytes.NewBuffer(nil)
+	stmMock.SendElementFunc = func(elem stravaganza.Element) <-chan error {
+		_ = elem.ToXML(output, true)
+		ch := make(chan error)
+		close(ch)
+		return ch
+	}
+
 	hk := hook.NewHooks()
 	m := &Offline{
 		cfg:    Config{QueueSize: 100},
@@ -208,6 +213,7 @@ func TestOffline_DeliverOfflineMessages(t *testing.T) {
 		Info: &hook.C2SStreamInfo{
 			Element: pr,
 		},
+		Sender:  stmMock,
 		Context: context.Background(),
 	})
 
diff --git a/pkg/module/roster/roster.go b/pkg/module/roster/roster.go
index 71f09229a..92b699be3 100644
--- a/pkg/module/roster/roster.go
+++ b/pkg/module/roster/roster.go
@@ -31,7 +31,6 @@ import (
 	"github.com/ortuman/jackal/pkg/host"
 	rostermodel "github.com/ortuman/jackal/pkg/model/roster"
 	"github.com/ortuman/jackal/pkg/router"
-	"github.com/ortuman/jackal/pkg/router/stream"
 	"github.com/ortuman/jackal/pkg/storage/repository"
 	xmpputil "github.com/ortuman/jackal/pkg/util/xmpp"
 )
@@ -204,7 +203,7 @@ func (r *Roster) sendRoster(ctx context.Context, iq *stravaganza.IQ) error {
 		if err != nil {
 			return err
 		}
-		stm, err := r.getStream(usrJID.Node(), usrJID.Resource())
+		stm, err := r.router.C2S().LocalStream(usrJID.Node(), usrJID.Resource())
 		if err != nil {
 			return err
 		}
@@ -234,7 +233,7 @@ func (r *Roster) sendRoster(ctx context.Context, iq *stravaganza.IQ) error {
 	if err != nil {
 		return err
 	}
-	stm, err := r.getStream(usrJID.Node(), usrJID.Resource())
+	stm, err := r.router.C2S().LocalStream(usrJID.Node(), usrJID.Resource())
 	if err != nil {
 		return err
 	}
@@ -553,7 +552,7 @@ func (r *Roster) processAvailability(ctx context.Context, presence *stravaganza.
 	}
 	isAvailable := presence.IsAvailable()
 	if isAvailable {
-		stm, err := r.getStream(fromJID.Node(), fromJID.Resource())
+		stm, err := r.router.C2S().LocalStream(fromJID.Node(), fromJID.Resource())
 		if err != nil {
 			return err
 		}
@@ -831,14 +830,6 @@ func (r *Roster) routePresencesFrom(ctx context.Context, username string, toJID
 	return nil
 }
 
-func (r *Roster) getStream(username, resource string) (stream.C2S, error) {
-	stm := r.router.C2S().LocalStream(username, resource)
-	if stm == nil {
-		return nil, errStreamNotFound(username, resource)
-	}
-	return stm, nil
-}
-
 func (r *Roster) runHook(ctx context.Context, hookName string, inf *hook.RosterInfo) error {
 	_, err := r.hk.Run(hookName, &hook.ExecutionContext{
 		Info:    inf,
@@ -914,7 +905,3 @@ func parseVer(ver string) int {
 	}
 	return 0
 }
-
-func errStreamNotFound(username, resource string) error {
-	return fmt.Errorf("roster: local stream not found: %s/%s", username, resource)
-}
diff --git a/pkg/module/roster/roster_test.go b/pkg/module/roster/roster_test.go
index 948797bcb..b9cda35c0 100644
--- a/pkg/module/roster/roster_test.go
+++ b/pkg/module/roster/roster_test.go
@@ -55,8 +55,8 @@ func TestRoster_SendRoster(t *testing.T) {
 		return nil
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return stmMock
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
 	}
 	routerMock := &routerMock{}
 
@@ -870,8 +870,8 @@ func TestRoster_Available(t *testing.T) {
 		return c2smodel.NewInfoMap()
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return stmMock
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
 	}
 
 	routerMock := &routerMock{}
diff --git a/pkg/module/xep0004/field.go b/pkg/module/xep0004/field.go
index 6396290dd..102e1dbc7 100644
--- a/pkg/module/xep0004/field.go
+++ b/pkg/module/xep0004/field.go
@@ -71,6 +71,7 @@ type Field struct {
 	Description string
 	Values      []string
 	Options     []Option
+	Validate    *Validate
 }
 
 // NewFieldFromElement returns a new form field entity reading it from it's XML representation.
@@ -110,6 +111,28 @@ func NewFieldFromElement(elem stravaganza.Element) (*Field, error) {
 		}
 		f.Options = append(f.Options, Option{Label: label, Value: value})
 	}
+
+	validateElem := elem.ChildNamespace("validate", validateNamespace)
+	if validateElem != nil {
+		v := &Validate{
+			DataType: validateElem.Attribute("datatype"),
+		}
+		if validateElem.Child("open") != nil {
+			v.Validator = &OpenValidator{}
+		} else if validateElem.Child("basic") != nil {
+			v.Validator = &BasicValidator{}
+		} else if rng := validateElem.Child("range"); rng != nil {
+			v.Validator = &RangeValidator{
+				Max: rng.Attribute("max"),
+				Min: rng.Attribute("min"),
+			}
+		} else if rgx := validateElem.Child("regex"); rgx != nil {
+			v.Validator = &RegExValidator{
+				RegEx: rgx.Text(),
+			}
+		}
+		f.Validate = v
+	}
 	return f, nil
 }
 
@@ -154,6 +177,9 @@ func (f *Field) Element() stravaganza.Element {
 		)
 		b.WithChild(sb.Build())
 	}
+	if f.Validate != nil {
+		b.WithChild(f.Validate.Element())
+	}
 	return b.Build()
 }
 
diff --git a/pkg/module/xep0004/field_test.go b/pkg/module/xep0004/field_test.go
index 565678372..12d0702fc 100644
--- a/pkg/module/xep0004/field_test.go
+++ b/pkg/module/xep0004/field_test.go
@@ -116,6 +116,12 @@ func TestField_Element(t *testing.T) {
 	f.Description = "A description"
 	f.Values = []string{"A value"}
 	f.Options = []Option{{"opt_label", "An option value"}}
+	f.Validate = &Validate{
+		DataType: BooleanDataType,
+		Validator: &RegExValidator{
+			RegEx: "([0-9]{3})-([0-9]{2})-([0-9]{4})",
+		},
+	}
 	elem := f.Element()
 
 	require.Equal(t, "field", elem.Name())
@@ -134,4 +140,11 @@ func TestField_Element(t *testing.T) {
 
 	valElem = optElem.Child("value")
 	require.Equal(t, "An option value", valElem.Text())
+
+	validateElem := elem.ChildNamespace("validate", validateNamespace)
+	require.NotNil(t, validateElem)
+
+	regexElem := validateElem.Child("regex")
+	require.NotNil(t, regexElem)
+	require.Equal(t, "([0-9]{3})-([0-9]{2})-([0-9]{4})", regexElem.Text())
 }
diff --git a/pkg/module/xep0004/fields.go b/pkg/module/xep0004/fields.go
index e1dec7415..3cf37a6a7 100644
--- a/pkg/module/xep0004/fields.go
+++ b/pkg/module/xep0004/fields.go
@@ -42,7 +42,7 @@ func (f Fields) ValuesForFieldOfType(fieldName, typ string) []string {
 	var res []string
 	for _, field := range f {
 		if field.Var == fieldName && field.Type == typ && len(field.Values) > 0 {
-			res = append(res, field.Values[0])
+			res = append(res, field.Values...)
 		}
 	}
 	return res
diff --git a/pkg/module/xep0004/validate.go b/pkg/module/xep0004/validate.go
new file mode 100644
index 000000000..a1651382b
--- /dev/null
+++ b/pkg/module/xep0004/validate.go
@@ -0,0 +1,116 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0004
+
+import "github.com/jackal-xmpp/stravaganza"
+
+const (
+	// StringDataType datatype represents character strings in XML.
+	StringDataType = "xs:string"
+
+	// BooleanDataType represents the values of two-valued logic.
+	BooleanDataType = "xs:boolean"
+
+	// DecimalDataType represents a subset of the real numbers, which can be represented by decimal numerals.
+	DecimalDataType = "xs:decimal"
+
+	// FloatDataType is patterned after the IEEE single-precision 32-bit floating point datatype
+	FloatDataType = "xs:float"
+
+	// DoubleDataType is patterned after the IEEE double-precision 64-bit floating point datatype.
+	DoubleDataType = "xs:double"
+
+	// DurationDataType is a datatype that represents durations of time.
+	DurationDataType = "xs:duration"
+
+	// DateTimeDataType represents instants of time, optionally marked with a particular time zone offset.
+	DateTimeDataType = "xs:dateTime"
+
+	// HexBinaryDataType represents arbitrary hex-encoded binary data.
+	HexBinaryDataType = "xs:hexBinary"
+
+	// Base64BinaryDataType represents arbitrary Base64-encoded binary data
+	Base64BinaryDataType = "xs:base64Binary"
+)
+
+const validateNamespace = "http://jabber.org/protocol/xdata-validate"
+
+// Validator defines validation type interface.
+type Validator interface {
+	Element() stravaganza.Element
+}
+
+// Validate represents a field validation type.
+type Validate struct {
+	DataType  string
+	Validator Validator
+}
+
+// Element returns validation type element representation.
+func (v *Validate) Element() stravaganza.Element {
+	b := stravaganza.NewBuilder("validate").
+		WithAttribute(stravaganza.Namespace, validateNamespace).
+		WithAttribute("datatype", v.DataType)
+	if v.Validator != nil {
+		b.WithChild(v.Validator.Element())
+	}
+	return b.Build()
+}
+
+// OpenValidator represents open validation type.
+type OpenValidator struct{}
+
+// Element satisfies Validator interface.
+func (v *OpenValidator) Element() stravaganza.Element {
+	return stravaganza.NewBuilder("open").Build()
+}
+
+// BasicValidator represents basic validation type.
+type BasicValidator struct{}
+
+// Element satisfies Validator interface.
+func (v *BasicValidator) Element() stravaganza.Element {
+	return stravaganza.NewBuilder("basic").Build()
+}
+
+// RangeValidator represents range validation type.
+type RangeValidator struct {
+	Min string
+	Max string
+}
+
+// Element satisfies Validator interface.
+func (v *RangeValidator) Element() stravaganza.Element {
+	b := stravaganza.NewBuilder("range")
+	if len(v.Min) > 0 {
+		b.WithAttribute("min", v.Min)
+	}
+	if len(v.Max) > 0 {
+		b.WithAttribute("max", v.Max)
+	}
+	return b.Build()
+}
+
+// RegExValidator represents regex validation type.
+type RegExValidator struct {
+	RegEx string
+}
+
+// Element satisfies Validator interface.
+func (v *RegExValidator) Element() stravaganza.Element {
+	b := stravaganza.NewBuilder("regex")
+	b.WithText(v.RegEx)
+	return b.Build()
+}
diff --git a/pkg/module/xep0004/validate_test.go b/pkg/module/xep0004/validate_test.go
new file mode 100644
index 000000000..82f0194de
--- /dev/null
+++ b/pkg/module/xep0004/validate_test.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0004
+
+import (
+	"testing"
+
+	"github.com/jackal-xmpp/stravaganza"
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidator_Element(t *testing.T) {
+	v := Validate{
+		DataType:  StringDataType,
+		Validator: &OpenValidator{},
+	}
+
+	elem := v.Element()
+
+	require.NotNil(t, elem)
+	require.Equal(t, "validate", elem.Name())
+	require.Equal(t, validateNamespace, elem.Attribute(stravaganza.Namespace))
+	require.Equal(t, StringDataType, elem.Attribute("datatype"))
+
+	validatorElem := elem.Child("open")
+	require.NotNil(t, validatorElem)
+	require.Equal(t, "open", validatorElem.Name())
+}
diff --git a/pkg/module/xep0049/private.go b/pkg/module/xep0049/private.go
index a568a60ce..3b1db61b4 100644
--- a/pkg/module/xep0049/private.go
+++ b/pkg/module/xep0049/private.go
@@ -18,6 +18,8 @@ import (
 	"context"
 	"strings"
 
+	"github.com/jackal-xmpp/stravaganza/jid"
+
 	kitlog "github.com/go-kit/log"
 	"github.com/go-kit/log/level"
 	"github.com/jackal-xmpp/stravaganza"
@@ -87,8 +89,8 @@ func (m *Private) MatchesNamespace(namespace string, serverTarget bool) bool {
 func (m *Private) ProcessIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	fromJid := iq.FromJID()
 	toJid := iq.ToJID()
-	validTo := toJid.Node() == fromJid.Node()
-	if !validTo {
+
+	if !fromJid.MatchesWithOptions(toJid, jid.MatchesBare) {
 		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.Forbidden))
 		return nil
 	}
diff --git a/pkg/module/xep0059/rsm.go b/pkg/module/xep0059/rsm.go
new file mode 100644
index 000000000..c54d45ec0
--- /dev/null
+++ b/pkg/module/xep0059/rsm.go
@@ -0,0 +1,241 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0059
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+
+	"github.com/jackal-xmpp/stravaganza"
+)
+
+const (
+	// RSMNamespace specifies XEP-0059 namespace constant value.
+	RSMNamespace = "http://jabber.org/protocol/rsm"
+)
+
+var (
+	// ErrPageNotFound will be returned by GetResultSetPage when page request cannot be satisfied.
+	ErrPageNotFound = errors.New("page not found")
+)
+
+// Request represents a rsm request value.
+type Request struct {
+	After    string
+	Before   string
+	Index    int
+	Max      int
+	LastPage bool
+}
+
+// Result represents a rsm result value.
+type Result struct {
+	Index    int
+	First    string
+	Last     string
+	Count    int
+	Complete bool
+}
+
+// NewRequestFromElement returns a Request derived from an XML element.
+func NewRequestFromElement(elem stravaganza.Element) (*Request, error) {
+	var req Request
+	var err error
+
+	if n := elem.Name(); n != "set" {
+		return nil, fmt.Errorf("xep0059: invalid set name: %s", n)
+	}
+	if ns := elem.Attribute(stravaganza.Namespace); ns != RSMNamespace {
+		return nil, fmt.Errorf("xep0059: invalid set namespace: %s", ns)
+	}
+	if maxEl := elem.Child("max"); maxEl != nil {
+		req.Max, err = strconv.Atoi(maxEl.Text())
+		if err != nil {
+			return nil, err
+		}
+	}
+	if indexEl := elem.Child("index"); indexEl != nil {
+		req.Index, err = strconv.Atoi(indexEl.Text())
+		if err != nil {
+			return nil, err
+		}
+	}
+	if afterEl := elem.Child("after"); afterEl != nil {
+		req.After = afterEl.Text()
+	}
+	if beforeEl := elem.Child("before"); beforeEl != nil {
+		if beforeID := beforeEl.Text(); len(beforeID) > 0 {
+			req.Before = beforeID
+		} else {
+			req.LastPage = true
+		}
+	}
+	return &req, nil
+}
+
+// Element returns XML representation of a Result instance.
+func (r *Result) Element() stravaganza.Element {
+	sb := stravaganza.NewBuilder("set").
+		WithAttribute(stravaganza.Namespace, RSMNamespace)
+
+	if len(r.First) > 0 {
+		sb.WithChild(
+			stravaganza.NewBuilder("first").
+				WithAttribute("index", strconv.Itoa(r.Index)).
+				WithText(r.First).
+				Build(),
+		)
+	}
+	if len(r.Last) > 0 {
+		sb.WithChild(
+			stravaganza.NewBuilder("last").
+				WithText(r.Last).
+				Build(),
+		)
+	}
+	sb.WithChild(
+		stravaganza.NewBuilder("count").
+			WithText(strconv.Itoa(r.Count)).
+			Build(),
+	)
+	return sb.Build()
+}
+
+// GetResultSetPage returns result page based on the passed request.
+func GetResultSetPage[T any](rs []T, req *Request, getID func(i T) string) ([]T, *Result, error) {
+	var page []T
+	var res *Result
+	var err error
+
+	switch {
+	case len(rs) == 0 && req.Index == 0:
+		return nil, &Result{Complete: true}, nil
+
+	case req.LastPage:
+		page, res, err = getPageByIndex(rs, lastIndex(len(rs), req.Max), req.Max)
+
+	case req.Index > 0:
+		page, res, err = getPageByIndex(rs, req.Index, req.Max)
+
+	case len(req.After) > 0:
+		page, res, err = getPageAfterID(rs, getID, req.After, req.Max)
+
+	case len(req.Before) > 0:
+		page, res, err = getPageBeforeID(rs, getID, req.Before, req.Max)
+
+	case req.Max == 0:
+		return nil, &Result{Count: len(rs)}, nil
+
+	default:
+		page, res, err = getPageByIndex(rs, 0, req.Max) // request first page
+	}
+	if err != nil {
+		return nil, nil, err
+	}
+	res.First = getID(page[0])
+	res.Last = getID(page[len(page)-1])
+
+	return page, res, nil
+}
+
+func getPageByIndex[T any](rs []T, idx, max int) ([]T, *Result, error) {
+	var page []T
+	var res Result
+
+	i := idx * max
+	if i > len(rs)-1 {
+		return nil, nil, ErrPageNotFound
+	}
+
+	lastIdx := len(rs) - 1
+	for ; i < len(rs) && res.Count < max; i++ {
+		if i >= lastIdx {
+			res.Complete = true
+		}
+		page = append(page, rs[i])
+		res.Count++
+	}
+	res.Index = idx
+
+	return page, &res, nil
+}
+
+func getPageAfterID[T any](rs []T, getID func(i T) string, id string, max int) ([]T, *Result, error) {
+	var page []T
+	var res Result
+
+	idIdx := getIDIndex(rs, getID, id)
+	if idIdx == -1 {
+		return nil, nil, ErrPageNotFound
+	}
+	startIdx := idIdx + 1
+
+	lastIdx := len(rs) - 1
+	for i := startIdx; i < len(rs) && res.Count < max; i++ {
+		if i >= lastIdx {
+			res.Complete = true
+		}
+		page = append(page, rs[i])
+		res.Count++
+	}
+	res.Index = startIdx / max
+
+	return page, &res, nil
+}
+
+func getPageBeforeID[T any](rs []T, getID func(i T) string, id string, max int) ([]T, *Result, error) {
+	var page []T
+	var res Result
+
+	idIdx := getIDIndex(rs, getID, id)
+	if idIdx == -1 {
+		return nil, nil, ErrPageNotFound
+	}
+	startIdx := idIdx - max
+	if startIdx < 0 {
+		startIdx = 0
+	}
+
+	lastIdx := len(rs) - 1
+	for i := startIdx; i < len(rs) && res.Count < max; i++ {
+		if i >= lastIdx {
+			res.Complete = true
+		}
+		page = append(page, rs[i])
+		res.Count++
+	}
+	res.Index = startIdx / max
+
+	return page, &res, nil
+}
+
+func getIDIndex[T any](rs []T, getID func(i T) string, id string) int {
+	for i := 0; i < len(rs); i++ {
+		if getID(rs[i]) != id {
+			continue
+		}
+		return i
+	}
+	return -1
+}
+
+func lastIndex(len, max int) int {
+	li := len/max - 1
+	if len%max > 0 {
+		li++
+	}
+	return li
+}
diff --git a/pkg/module/xep0059/rsm_test.go b/pkg/module/xep0059/rsm_test.go
new file mode 100644
index 000000000..0d70e6a75
--- /dev/null
+++ b/pkg/module/xep0059/rsm_test.go
@@ -0,0 +1,158 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0059
+
+import (
+	"testing"
+
+	"github.com/jackal-xmpp/stravaganza"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRequest_NewFromElement(t *testing.T) {
+	// given
+	el := stravaganza.NewBuilder("set").
+		WithAttribute(stravaganza.Namespace, RSMNamespace).
+		WithChild(
+			stravaganza.NewBuilder("max").
+				WithText("10").
+				Build(),
+		).
+		WithChild(
+			stravaganza.NewBuilder("index").
+				WithText("1").
+				Build(),
+		).
+		WithChild(
+			stravaganza.NewBuilder("after").
+				WithText("peter@pixyland.org").
+				Build(),
+		).
+		WithChild(
+			stravaganza.NewBuilder("before").
+				WithText("peter@rabbit.lit").
+				Build(),
+		).
+		Build()
+
+	// when
+	req, err := NewRequestFromElement(el)
+
+	// then
+	require.NoError(t, err)
+
+	require.Equal(t, 10, req.Max)
+	require.Equal(t, 1, req.Index)
+	require.Equal(t, "peter@pixyland.org", req.After)
+	require.Equal(t, "peter@rabbit.lit", req.Before)
+}
+
+func TestResult_Element(t *testing.T) {
+	// given
+	r := Result{
+		Index: 1,
+		First: "f0",
+		Last:  "l1",
+		Count: 800,
+	}
+
+	// when
+	el := r.Element()
+
+	// then
+	require.Equal(t, `<set xmlns='http://jabber.org/protocol/rsm'><first index='1'>f0</first><last>l1</last><count>800</count></set>`, el.String())
+}
+
+func Test_GetResultSetPage(t *testing.T) {
+	tcs := map[string]struct {
+		rs             []string
+		req            Request
+		expectedPage   []string
+		expectedResult Result
+		expectsError   bool
+	}{
+		"empty set": {
+			req:            Request{Max: 10},
+			expectedResult: Result{Count: 0, Complete: true},
+		},
+		"get page by index": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{Index: 2, Max: 3},
+			expectedPage:   []string{"7", "8", "9"},
+			expectedResult: Result{Index: 2, Count: 3, First: "7", Last: "9"},
+		},
+		"get out of bound index": {
+			rs:           []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:          Request{Index: 4, Max: 3},
+			expectsError: true,
+		},
+		"get last page": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{LastPage: true, Max: 3},
+			expectedPage:   []string{"10"},
+			expectedResult: Result{Index: 3, Count: 1, First: "10", Last: "10", Complete: true},
+		},
+		"get page after id": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{After: "3", Max: 4},
+			expectedPage:   []string{"4", "5", "6", "7"},
+			expectedResult: Result{Index: 0, Count: 4, First: "4", Last: "7"},
+		},
+		"get page after id - last page": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{After: "8", Max: 4},
+			expectedPage:   []string{"9", "10"},
+			expectedResult: Result{Index: 2, Count: 2, First: "9", Last: "10", Complete: true},
+		},
+		"get page after id - not found": {
+			rs:           []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:          Request{After: "11", Max: 4},
+			expectsError: true,
+		},
+		"get before id": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{Before: "9", Max: 2},
+			expectedPage:   []string{"7", "8"},
+			expectedResult: Result{Index: 3, Count: 2, First: "7", Last: "8"},
+		},
+		"get before id - first page": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{Before: "2", Max: 4},
+			expectedPage:   []string{"1", "2", "3", "4"},
+			expectedResult: Result{Index: 0, Count: 4, First: "1", Last: "4"},
+		},
+		"get before id - not found": {
+			rs:           []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:          Request{Before: "11", Max: 4},
+			expectsError: true,
+		},
+		"get results count": {
+			rs:             []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"},
+			req:            Request{Max: 0},
+			expectedResult: Result{Count: 10},
+		},
+	}
+	for tName, tc := range tcs {
+		t.Run(tName, func(t *testing.T) {
+			page, res, err := GetResultSetPage(tc.rs, &tc.req, func(s string) string { return s })
+			if tc.expectsError {
+				require.Error(t, err)
+			} else {
+				require.Equal(t, &tc.expectedResult, res)
+				require.Equal(t, tc.expectedPage, page)
+			}
+		})
+	}
+}
diff --git a/pkg/module/xep0191/blocklist.go b/pkg/module/xep0191/blocklist.go
index 5ea173e6a..9e818aecc 100644
--- a/pkg/module/xep0191/blocklist.go
+++ b/pkg/module/xep0191/blocklist.go
@@ -16,7 +16,6 @@ package xep0191
 
 import (
 	"context"
-	"fmt"
 
 	kitlog "github.com/go-kit/log"
 	"github.com/go-kit/log/level"
@@ -111,7 +110,8 @@ func (m *BlockList) MatchesNamespace(namespace string, serverTarget bool) bool {
 func (m *BlockList) ProcessIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	fromJID := iq.FromJID()
 	toJID := iq.ToJID()
-	if fromJID.Node() != toJID.Node() {
+
+	if !fromJID.MatchesWithOptions(toJID, jid.MatchesBare) {
 		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.Forbidden))
 		return nil
 	}
@@ -288,10 +288,10 @@ func (m *BlockList) getBlockList(ctx context.Context, iq *stravaganza.IQ) error
 	username := fromJID.Node()
 	res := fromJID.Resource()
 
-	stm := m.router.C2S().LocalStream(username, res)
-	if stm == nil {
+	stm, err := m.router.C2S().LocalStream(username, res)
+	if err != nil {
 		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.InternalServerError))
-		return fmt.Errorf("xep0191: local stream not found: %s/%s", username, res)
+		return err
 	}
 	if err := stm.SetInfoValue(ctx, blockListRequestedCtxKey, true); err != nil {
 		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.InternalServerError))
diff --git a/pkg/module/xep0191/blocklist_test.go b/pkg/module/xep0191/blocklist_test.go
index 1a5f4ba75..7f19c63f4 100644
--- a/pkg/module/xep0191/blocklist_test.go
+++ b/pkg/module/xep0191/blocklist_test.go
@@ -47,8 +47,8 @@ func TestBlockList_GetBlockList(t *testing.T) {
 		return nil
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return stmMock
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
 	}
 
 	var respStanzas []stravaganza.Stanza
diff --git a/pkg/module/xep0198/stream.go b/pkg/module/xep0198/stream.go
index 3dab7ffb4..c6f991df6 100644
--- a/pkg/module/xep0198/stream.go
+++ b/pkg/module/xep0198/stream.go
@@ -25,20 +25,17 @@ import (
 	"sync"
 	"time"
 
-	"github.com/ortuman/jackal/pkg/cluster/instance"
-
-	clusterconnmanager "github.com/ortuman/jackal/pkg/cluster/connmanager"
-
-	streamqueue "github.com/ortuman/jackal/pkg/module/xep0198/queue"
-
 	kitlog "github.com/go-kit/log"
 	"github.com/go-kit/log/level"
 	"github.com/jackal-xmpp/stravaganza"
 	streamerror "github.com/jackal-xmpp/stravaganza/errors/stream"
 	"github.com/jackal-xmpp/stravaganza/jid"
+	clusterconnmanager "github.com/ortuman/jackal/pkg/cluster/connmanager"
+	"github.com/ortuman/jackal/pkg/cluster/instance"
 	"github.com/ortuman/jackal/pkg/cluster/resourcemanager"
 	"github.com/ortuman/jackal/pkg/hook"
 	"github.com/ortuman/jackal/pkg/host"
+	streamqueue "github.com/ortuman/jackal/pkg/module/xep0198/queue"
 	xmppparser "github.com/ortuman/jackal/pkg/parser"
 	"github.com/ortuman/jackal/pkg/router"
 	"github.com/ortuman/jackal/pkg/router/stream"
@@ -236,8 +233,11 @@ func (m *Stream) onDisconnect(execCtx *hook.ExecutionContext) error {
 
 	inf := execCtx.Info.(*hook.C2SStreamInfo)
 	discErr := inf.DisconnectError
-	_, ok := discErr.(*streamerror.Error)
-	if ok || errors.Is(discErr, xmppparser.ErrStreamClosedByPeer) {
+	_, isStreamErr := discErr.(*streamerror.Error)
+
+	shouldHibernate := inf.Presence.IsAvailable() && !isStreamErr && !errors.Is(discErr, xmppparser.ErrStreamClosedByPeer)
+
+	if !shouldHibernate {
 		return nil
 	}
 	// schedule stream termination
diff --git a/pkg/module/xep0199/ping.go b/pkg/module/xep0199/ping.go
index 1eb3dd2c3..db4f3f621 100644
--- a/pkg/module/xep0199/ping.go
+++ b/pkg/module/xep0199/ping.go
@@ -214,7 +214,8 @@ func (p *Ping) timeout(jd *jid.JID) {
 	// perform timeout action
 	switch p.cfg.TimeoutAction {
 	case killAction:
-		if stm := p.router.C2S().LocalStream(jd.Node(), jd.Resource()); stm != nil {
+		stm, _ := p.router.C2S().LocalStream(jd.Node(), jd.Resource())
+		if stm != nil {
 			_ = stm.Disconnect(streamerror.E(streamerror.ConnectionTimeout))
 		}
 	}
diff --git a/pkg/module/xep0199/ping_test.go b/pkg/module/xep0199/ping_test.go
index d3643b390..4cab64230 100644
--- a/pkg/module/xep0199/ping_test.go
+++ b/pkg/module/xep0199/ping_test.go
@@ -111,8 +111,8 @@ func TestPing_Timeout(t *testing.T) {
 		return nil
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return c2sStream
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return c2sStream, nil
 	}
 	routerMock.C2SFunc = func() router.C2SRouter {
 		return c2sRouterMock
diff --git a/pkg/module/xep0280/carbons.go b/pkg/module/xep0280/carbons.go
index 02916a62c..2f4b8d000 100644
--- a/pkg/module/xep0280/carbons.go
+++ b/pkg/module/xep0280/carbons.go
@@ -16,7 +16,8 @@ package xep0280
 
 import (
 	"context"
-	"fmt"
+
+	"github.com/ortuman/jackal/pkg/module/xep0313"
 
 	kitlog "github.com/go-kit/log"
 	"github.com/go-kit/log/level"
@@ -36,7 +37,6 @@ const (
 
 	carbonsNamespace          = "urn:xmpp:carbons:2"
 	deliveryReceiptsNamespace = "urn:xmpp:receipts"
-	forwardingNamespace       = "urn:xmpp:forward:0"
 	chatStatesNamespace       = "http://jabber.org/protocol/chatstates"
 	hintsNamespace            = "urn:xmpp:hints"
 )
@@ -97,8 +97,8 @@ func (p *Carbons) AccountFeatures(_ context.Context) ([]string, error) {
 func (p *Carbons) Start(_ context.Context) error {
 	p.hk.AddHook(hook.C2SStreamWillRouteElement, p.onC2SElementWillRoute, hook.DefaultPriority)
 	p.hk.AddHook(hook.S2SInStreamWillRouteElement, p.onS2SElementWillRoute, hook.DefaultPriority)
-	p.hk.AddHook(hook.C2SStreamMessageRouted, p.onC2SMessageRouted, hook.DefaultPriority)
-	p.hk.AddHook(hook.S2SInStreamMessageRouted, p.onS2SMessageRouted, hook.DefaultPriority)
+	p.hk.AddHook(hook.C2SStreamMessageRouted, p.onC2SMessageRouted, hook.LowestPriority+1)
+	p.hk.AddHook(hook.S2SInStreamMessageRouted, p.onS2SMessageRouted, hook.LowestPriority+1)
 
 	level.Info(p.logger).Log("msg", "started carbons module")
 	return nil
@@ -175,7 +175,7 @@ func (p *Carbons) onS2SMessageRouted(execCtx *hook.ExecutionContext) error {
 	if !ok {
 		return nil
 	}
-	return p.processMessage(ctx, msg, nil)
+	return p.processMessage(ctx, msg, inf.Targets)
 }
 
 func (p *Carbons) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
@@ -207,9 +207,9 @@ func (p *Carbons) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 }
 
 func (p *Carbons) setCarbonsEnabled(ctx context.Context, username, resource string, enabled bool) error {
-	stm := p.router.C2S().LocalStream(username, resource)
-	if stm == nil {
-		return errStreamNotFound(username, resource)
+	stm, err := p.router.C2S().LocalStream(username, resource)
+	if err != nil {
+		return err
 	}
 	return stm.SetInfoValue(ctx, carbonsEnabledCtxKey, enabled)
 }
@@ -243,7 +243,7 @@ func (p *Carbons) routeSentCC(ctx context.Context, msg *stravaganza.Message, use
 		if !res.Info().Bool(carbonsEnabledCtxKey) {
 			continue
 		}
-		_, _ = p.router.Route(ctx, sentMsgCC(msg, res.JID()))
+		_, _ = p.router.Route(ctx, sentMsgCC(ctx, msg, res.JID()))
 	}
 	return nil
 }
@@ -257,7 +257,7 @@ func (p *Carbons) routeReceivedCC(ctx context.Context, msg *stravaganza.Message,
 		if !res.Info().Bool(carbonsEnabledCtxKey) {
 			continue
 		}
-		_, _ = p.router.Route(ctx, receivedMsgCC(msg, res.JID()))
+		_, _ = p.router.Route(ctx, receivedMsgCC(ctx, msg, res.JID()))
 	}
 	return nil
 }
@@ -316,46 +316,38 @@ func isCCMessage(msg *stravaganza.Message) bool {
 	return msg.ChildNamespace("sent", carbonsNamespace) != nil || msg.ChildNamespace("received", carbonsNamespace) != nil
 }
 
-func sentMsgCC(msg *stravaganza.Message, dest *jid.JID) *stravaganza.Message {
+func sentMsgCC(ctx context.Context, originalMsg *stravaganza.Message, dest *jid.JID) *stravaganza.Message {
+	msg := originalMsg
+	if sentArchiveID := xep0313.ExtractSentArchiveID(ctx); len(sentArchiveID) > 0 {
+		msg = xmpputil.MakeStanzaIDMessage(msg, sentArchiveID, dest.ToBareJID().String())
+	}
 	ccMsg, _ := stravaganza.NewMessageBuilder().
 		WithAttribute(stravaganza.From, dest.ToBareJID().String()).
 		WithAttribute(stravaganza.To, dest.String()).
-		WithAttribute(stravaganza.Type, stravaganza.ChatType).
+		WithAttribute(stravaganza.Type, msg.Type()).
 		WithChild(
 			stravaganza.NewBuilder("sent").
 				WithAttribute(stravaganza.Namespace, carbonsNamespace).
-				WithChild(
-					stravaganza.NewBuilder("forwarded").
-						WithAttribute(stravaganza.Namespace, forwardingNamespace).
-						WithChild(msg).
-						Build(),
-				).
+				WithChild(xmpputil.MakeForwardedStanza(msg, nil)).
 				Build(),
-		).
-		BuildMessage()
+		).BuildMessage()
 	return ccMsg
 }
 
-func receivedMsgCC(msg *stravaganza.Message, dest *jid.JID) *stravaganza.Message {
+func receivedMsgCC(ctx context.Context, originalMsg *stravaganza.Message, dest *jid.JID) *stravaganza.Message {
+	msg := originalMsg
+	if receivedArchiveID := xep0313.ExtractReceivedArchiveID(ctx); len(receivedArchiveID) > 0 {
+		msg = xmpputil.MakeStanzaIDMessage(msg, receivedArchiveID, dest.ToBareJID().String())
+	}
 	ccMsg, _ := stravaganza.NewMessageBuilder().
 		WithAttribute(stravaganza.From, dest.ToBareJID().String()).
 		WithAttribute(stravaganza.To, dest.String()).
-		WithAttribute(stravaganza.Type, stravaganza.ChatType).
+		WithAttribute(stravaganza.Type, msg.Type()).
 		WithChild(
 			stravaganza.NewBuilder("received").
 				WithAttribute(stravaganza.Namespace, carbonsNamespace).
-				WithChild(
-					stravaganza.NewBuilder("forwarded").
-						WithAttribute(stravaganza.Namespace, forwardingNamespace).
-						WithChild(msg).
-						Build(),
-				).
+				WithChild(xmpputil.MakeForwardedStanza(msg, nil)).
 				Build(),
-		).
-		BuildMessage()
+		).BuildMessage()
 	return ccMsg
 }
-
-func errStreamNotFound(username, resource string) error {
-	return fmt.Errorf("xep0280: local stream not found: %s/%s", username, resource)
-}
diff --git a/pkg/module/xep0280/carbons_test.go b/pkg/module/xep0280/carbons_test.go
index 2f8994aee..fee217402 100644
--- a/pkg/module/xep0280/carbons_test.go
+++ b/pkg/module/xep0280/carbons_test.go
@@ -44,8 +44,8 @@ func TestCarbons_Enable(t *testing.T) {
 		return c2smodel.NewInfoMap()
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return stmMock
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
 	}
 
 	routerMock := &routerMock{}
@@ -112,8 +112,8 @@ func TestCarbons_Disable(t *testing.T) {
 		return c2smodel.NewInfoMap()
 	}
 	c2sRouterMock := &c2sRouterMock{}
-	c2sRouterMock.LocalStreamFunc = func(username string, resource string) stream.C2S {
-		return stmMock
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
 	}
 
 	routerMock := &routerMock{}
diff --git a/pkg/module/xep0313/interface.go b/pkg/module/xep0313/interface.go
new file mode 100644
index 000000000..3153fbbc5
--- /dev/null
+++ b/pkg/module/xep0313/interface.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0313
+
+import (
+	"github.com/ortuman/jackal/pkg/router"
+	"github.com/ortuman/jackal/pkg/router/stream"
+	"github.com/ortuman/jackal/pkg/storage/repository"
+)
+
+//go:generate moq -out repository.mock_test.go . globalRepository:repositoryMock
+type globalRepository interface {
+	repository.Repository
+}
+
+//go:generate moq -out tx.mock_test.go . repTransaction:txMock
+type repTransaction interface {
+	repository.Transaction
+}
+
+//go:generate moq -out router.mock_test.go . globalRouter:routerMock
+type globalRouter interface {
+	router.Router
+}
+
+//go:generate moq -out c2srouter.mock_test.go . c2sRouter
+type c2sRouter interface {
+	router.C2SRouter
+}
+
+//go:generate moq -out stream.mock_test.go . c2sStream
+type c2sStream interface {
+	stream.C2S
+}
+
+//go:generate moq -out hosts.mock_test.go . hosts
+type hosts interface {
+	IsLocalHost(h string) bool
+}
diff --git a/pkg/module/xep0313/mam.go b/pkg/module/xep0313/mam.go
new file mode 100644
index 000000000..4fa8e23df
--- /dev/null
+++ b/pkg/module/xep0313/mam.go
@@ -0,0 +1,522 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0313
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	kitlog "github.com/go-kit/log"
+	"github.com/go-kit/log/level"
+	"github.com/google/uuid"
+	"github.com/jackal-xmpp/stravaganza"
+	stanzaerror "github.com/jackal-xmpp/stravaganza/errors/stanza"
+	"github.com/jackal-xmpp/stravaganza/jid"
+	"github.com/ortuman/jackal/pkg/hook"
+	"github.com/ortuman/jackal/pkg/host"
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	c2smodel "github.com/ortuman/jackal/pkg/model/c2s"
+	"github.com/ortuman/jackal/pkg/module/xep0004"
+	"github.com/ortuman/jackal/pkg/module/xep0059"
+	"github.com/ortuman/jackal/pkg/router"
+	"github.com/ortuman/jackal/pkg/storage/repository"
+	xmpputil "github.com/ortuman/jackal/pkg/util/xmpp"
+	"github.com/samber/lo"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+const (
+	// ModuleName represents mam module name.
+	ModuleName = "mam"
+
+	// XEPNumber represents mam XEP number.
+	XEPNumber = "0313"
+
+	mamNamespace         = "urn:xmpp:mam:2"
+	extendedMamNamespace = "urn:xmpp:mam:2#extended"
+
+	dateTimeFormat = "2006-01-02T15:04:05Z"
+
+	archiveRequestedCtxKey = "mam:requested"
+
+	defaultPageSize = 50
+	maxPageSize     = 250
+)
+
+type archiveIDCtxKey int
+
+const (
+	sentArchiveIDKey archiveIDCtxKey = iota
+	receivedArchiveIDKey
+)
+
+// Config contains mam module configuration options.
+type Config struct {
+	// QueueSize defines maximum number of archive messages stanzas.
+	// When the limit is reached, the oldest message will be purged to make room for the new one.
+	QueueSize int `fig:"queue_size" default:"1000"`
+}
+
+// Mam represents a mam (XEP-0313) module type.
+type Mam struct {
+	cfg    Config
+	hosts  hosts
+	router router.Router
+	hk     *hook.Hooks
+	rep    repository.Repository
+	logger kitlog.Logger
+}
+
+// New returns a new initialized mam instance.
+func New(
+	cfg Config,
+	router router.Router,
+	hosts *host.Hosts,
+	rep repository.Repository,
+	hk *hook.Hooks,
+	logger kitlog.Logger,
+) *Mam {
+	return &Mam{
+		cfg:    cfg,
+		router: router,
+		hosts:  hosts,
+		rep:    rep,
+		hk:     hk,
+		logger: kitlog.With(logger, "module", ModuleName, "xep", XEPNumber),
+	}
+}
+
+// Name returns mam module name.
+func (m *Mam) Name() string { return ModuleName }
+
+// StreamFeature returns mam module stream feature.
+func (m *Mam) StreamFeature(_ context.Context, _ string) (stravaganza.Element, error) {
+	return nil, nil
+}
+
+// ServerFeatures returns mam server disco features.
+func (m *Mam) ServerFeatures(_ context.Context) ([]string, error) {
+	return nil, nil
+}
+
+// AccountFeatures returns mam account disco features.
+func (m *Mam) AccountFeatures(_ context.Context) ([]string, error) {
+	return []string{mamNamespace, extendedMamNamespace}, nil
+}
+
+// Start starts mam module.
+func (m *Mam) Start(_ context.Context) error {
+	m.hk.AddHook(hook.C2SStreamMessageReceived, m.onMessageReceived, hook.HighestPriority)
+	m.hk.AddHook(hook.S2SInStreamMessageReceived, m.onMessageReceived, hook.HighestPriority)
+
+	m.hk.AddHook(hook.C2SStreamMessageRouted, m.onMessageRouted, hook.LowestPriority+2)
+	m.hk.AddHook(hook.S2SInStreamMessageRouted, m.onMessageRouted, hook.LowestPriority+2)
+	m.hk.AddHook(hook.UserDeleted, m.onUserDeleted, hook.DefaultPriority)
+
+	level.Info(m.logger).Log("msg", "started mam module")
+	return nil
+}
+
+// Stop stops mam module.
+func (m *Mam) Stop(_ context.Context) error {
+	m.hk.RemoveHook(hook.C2SStreamMessageReceived, m.onMessageReceived)
+	m.hk.RemoveHook(hook.S2SInStreamMessageReceived, m.onMessageReceived)
+	m.hk.RemoveHook(hook.C2SStreamMessageRouted, m.onMessageRouted)
+	m.hk.RemoveHook(hook.S2SInStreamMessageRouted, m.onMessageRouted)
+	m.hk.RemoveHook(hook.UserDeleted, m.onUserDeleted)
+
+	level.Info(m.logger).Log("msg", "stopped mam module")
+	return nil
+}
+
+// MatchesNamespace tells whether namespace matches mam module.
+func (m *Mam) MatchesNamespace(namespace string, serverTarget bool) bool {
+	if serverTarget {
+		return false
+	}
+	return namespace == mamNamespace
+}
+
+// ProcessIQ process a mam iq.
+func (m *Mam) ProcessIQ(ctx context.Context, iq *stravaganza.IQ) error {
+	fromJID := iq.FromJID()
+	toJID := iq.ToJID()
+
+	if !fromJID.MatchesWithOptions(toJID, jid.MatchesBare) {
+		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.Forbidden))
+		return nil
+	}
+	switch {
+	case iq.IsGet() && iq.ChildNamespace("metadata", mamNamespace) != nil:
+		return m.sendArchiveMetadata(ctx, iq)
+
+	case iq.IsGet() && iq.ChildNamespace("query", mamNamespace) != nil:
+		return m.sendFormFields(ctx, iq)
+
+	case iq.IsSet() && iq.ChildNamespace("query", mamNamespace) != nil:
+		return m.sendArchiveMessages(ctx, iq)
+	}
+	return nil
+}
+
+func (m *Mam) sendArchiveMetadata(ctx context.Context, iq *stravaganza.IQ) error {
+	archiveID := iq.FromJID().Node()
+
+	metadata, err := m.rep.FetchArchiveMetadata(ctx, archiveID)
+	if err != nil {
+		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.InternalServerError))
+		return err
+	}
+	// send reply
+	metadataBuilder := stravaganza.NewBuilder("metadata").WithAttribute(stravaganza.Namespace, mamNamespace)
+
+	startBuilder := stravaganza.NewBuilder("start")
+	if metadata != nil {
+		startBuilder.WithAttribute("id", metadata.StartId)
+		startBuilder.WithAttribute("timestamp", metadata.StartTimestamp)
+	}
+	endBuilder := stravaganza.NewBuilder("end")
+	if metadata != nil {
+		endBuilder.WithAttribute("id", metadata.EndId)
+		endBuilder.WithAttribute("timestamp", metadata.EndTimestamp)
+	}
+
+	metadataBuilder.WithChildren(startBuilder.Build(), endBuilder.Build())
+
+	resIQ := xmpputil.MakeResultIQ(iq, metadataBuilder.Build())
+	_, _ = m.router.Route(ctx, resIQ)
+
+	level.Info(m.logger).Log("msg", "requested archive metadata", "archive_id", archiveID)
+
+	return nil
+}
+
+func (m *Mam) sendFormFields(ctx context.Context, iq *stravaganza.IQ) error {
+	form := xep0004.DataForm{
+		Type: xep0004.Form,
+	}
+
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type:   xep0004.Hidden,
+		Var:    xep0004.FormType,
+		Values: []string{mamNamespace},
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.JidSingle,
+		Var:  "with",
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.TextSingle,
+		Var:  "start",
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.TextSingle,
+		Var:  "end",
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.TextSingle,
+		Var:  "before-id",
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.TextSingle,
+		Var:  "after-id",
+	})
+	form.Fields = append(form.Fields, xep0004.Field{
+		Type: xep0004.ListMulti,
+		Var:  "ids",
+		Validate: &xep0004.Validate{
+			DataType:  xep0004.StringDataType,
+			Validator: &xep0004.OpenValidator{},
+		},
+	})
+
+	qChild := stravaganza.NewBuilder("query").
+		WithAttribute(stravaganza.Namespace, mamNamespace).
+		WithChild(form.Element()).
+		Build()
+
+	_, _ = m.router.Route(ctx, xmpputil.MakeResultIQ(iq, qChild))
+
+	level.Info(m.logger).Log("msg", "requested form fields")
+
+	return nil
+}
+
+func (m *Mam) sendArchiveMessages(ctx context.Context, iq *stravaganza.IQ) error {
+	fromJID := iq.FromJID()
+
+	stm, err := m.router.C2S().LocalStream(fromJID.Node(), fromJID.Resource())
+	if err != nil {
+		return err
+	}
+
+	qChild := iq.ChildNamespace("query", mamNamespace)
+
+	// filter archive result
+	filters := &archivemodel.Filters{}
+	if x := qChild.ChildNamespace("x", xep0004.FormNamespace); x != nil {
+		form, err := xep0004.NewFormFromElement(x)
+		if err != nil {
+			return err
+		}
+		filters, err = formToFilters(form)
+		if err != nil {
+			return err
+		}
+	}
+	archiveID := fromJID.Node()
+
+	messages, err := m.rep.FetchArchiveMessages(ctx, filters, archiveID)
+	if err != nil {
+		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.InternalServerError))
+		return err
+	}
+
+	// return not found error if any requested id cannot be found
+	switch {
+	case len(filters.Ids) > 0 && (len(messages) != len(filters.Ids)):
+		fallthrough
+
+	case (len(filters.AfterId) > 0 || len(filters.BeforeId) > 0) && len(messages) == 0:
+		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.ItemNotFound))
+		return nil
+	}
+
+	// apply RSM paging
+	var req *xep0059.Request
+	var res *xep0059.Result
+
+	if set := qChild.ChildNamespace("set", xep0059.RSMNamespace); set != nil {
+		req, err = xep0059.NewRequestFromElement(set)
+		if err != nil {
+			_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.BadRequest))
+			return err
+		}
+		if req.Max > maxPageSize {
+			req.Max = maxPageSize
+		}
+	} else {
+		req = &xep0059.Request{Max: defaultPageSize}
+	}
+	messages, res, err = xep0059.GetResultSetPage(messages, req, func(m *archivemodel.Message) string {
+		return m.Id
+	})
+	if err != nil {
+		if errors.Is(err, xep0059.ErrPageNotFound) {
+			_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.ItemNotFound))
+			return nil
+		}
+		_, _ = m.router.Route(ctx, xmpputil.MakeErrorStanza(iq, stanzaerror.InternalServerError))
+		return err
+	}
+
+	// flip result page
+	if qChild.Child("flip-page") != nil {
+		messages = lo.Reverse(messages)
+
+		lastID := res.Last
+		res.Last = res.First
+		res.First = lastID
+	}
+
+	// route archive messages
+	for _, msg := range messages {
+		msgStanza, _ := stravaganza.NewBuilderFromProto(msg.Message).
+			BuildStanza()
+		stamp := msg.Stamp.AsTime()
+
+		resultElem := stravaganza.NewBuilder("result").
+			WithAttribute(stravaganza.Namespace, mamNamespace).
+			WithAttribute("queryid", qChild.Attribute("queryid")).
+			WithAttribute(stravaganza.ID, uuid.New().String()).
+			WithChild(xmpputil.MakeForwardedStanza(msgStanza, &stamp)).
+			Build()
+
+		archiveMsg, _ := stravaganza.NewMessageBuilder().
+			WithAttribute(stravaganza.From, iq.ToJID().String()).
+			WithAttribute(stravaganza.To, iq.FromJID().String()).
+			WithAttribute(stravaganza.ID, uuid.New().String()).
+			WithChild(resultElem).
+			BuildMessage()
+
+		_, _ = m.router.Route(ctx, archiveMsg)
+	}
+
+	finB := stravaganza.NewBuilder("fin").
+		WithChild(res.Element()).
+		WithAttribute(stravaganza.Namespace, mamNamespace)
+	if res.Complete {
+		finB.WithAttribute("complete", "true")
+	}
+	_, _ = m.router.Route(ctx, xmpputil.MakeResultIQ(iq, finB.Build()))
+
+	level.Info(m.logger).Log("msg", "archive messages requested", "archive_id", fromJID.Node(), "count", len(messages), "complete", res.Complete)
+
+	return stm.SetInfoValue(ctx, archiveRequestedCtxKey, true)
+}
+
+func (m *Mam) onMessageReceived(execCtx *hook.ExecutionContext) error {
+	var msg *stravaganza.Message
+
+	switch inf := execCtx.Info.(type) {
+	case *hook.C2SStreamInfo:
+		msg = inf.Element.(*stravaganza.Message)
+		inf.Element = m.addRecipientStanzaID(msg)
+		execCtx.Info = inf
+
+	case *hook.S2SStreamInfo:
+		msg = inf.Element.(*stravaganza.Message)
+		inf.Element = m.addRecipientStanzaID(msg)
+		execCtx.Info = inf
+	}
+	return nil
+}
+
+func (m *Mam) onMessageRouted(execCtx *hook.ExecutionContext) error {
+	var elem stravaganza.Element
+
+	switch inf := execCtx.Info.(type) {
+	case *hook.C2SStreamInfo:
+		elem = inf.Element
+	case *hook.S2SStreamInfo:
+		elem = inf.Element
+	}
+	return m.handleRoutedMessage(execCtx, elem)
+}
+
+func (m *Mam) onUserDeleted(execCtx *hook.ExecutionContext) error {
+	inf := execCtx.Info.(*hook.UserInfo)
+	return m.rep.DeleteArchive(execCtx.Context, inf.Username)
+}
+
+func (m *Mam) handleRoutedMessage(execCtx *hook.ExecutionContext, elem stravaganza.Element) error {
+	msg, ok := elem.(*stravaganza.Message)
+	if !ok {
+		return nil
+	}
+	if !isMessageArchievable(msg) {
+		return nil
+	}
+
+	fromJID := msg.FromJID()
+	if m.hosts.IsLocalHost(fromJID.Domain()) {
+		sentArchiveID := uuid.New().String()
+		archiveMsg := xmpputil.MakeStanzaIDMessage(msg, sentArchiveID, fromJID.ToBareJID().String())
+		if err := m.archiveMessage(execCtx.Context, archiveMsg, fromJID.Node(), sentArchiveID); err != nil {
+			return err
+		}
+		execCtx.Context = context.WithValue(execCtx.Context, sentArchiveIDKey, sentArchiveID)
+	}
+	toJID := msg.ToJID()
+	if !m.hosts.IsLocalHost(toJID.Domain()) {
+		return nil
+	}
+	recievedArchiveID := xmpputil.MessageStanzaID(msg)
+	if err := m.archiveMessage(execCtx.Context, msg, toJID.Node(), recievedArchiveID); err != nil {
+		return err
+	}
+	execCtx.Context = context.WithValue(execCtx.Context, receivedArchiveIDKey, recievedArchiveID)
+	return nil
+}
+
+func (m *Mam) archiveMessage(ctx context.Context, message *stravaganza.Message, archiveID, id string) error {
+	return m.rep.InTransaction(ctx, func(ctx context.Context, tx repository.Transaction) error {
+		err := tx.InsertArchiveMessage(ctx, &archivemodel.Message{
+			ArchiveId: archiveID,
+			Id:        id,
+			FromJid:   message.FromJID().String(),
+			ToJid:     message.ToJID().String(),
+			Message:   message.Proto(),
+			Stamp:     timestamppb.Now(),
+		})
+		if err != nil {
+			return err
+		}
+		return tx.DeleteArchiveOldestMessages(ctx, archiveID, m.cfg.QueueSize)
+	})
+}
+
+func (m *Mam) addRecipientStanzaID(originalMsg *stravaganza.Message) *stravaganza.Message {
+	toJID := originalMsg.ToJID()
+	if !m.hosts.IsLocalHost(toJID.Domain()) {
+		return originalMsg
+	}
+	archiveID := uuid.New().String()
+	return xmpputil.MakeStanzaIDMessage(originalMsg, archiveID, toJID.ToBareJID().String())
+}
+
+// IsArchiveRequested determines whether archive has been requested over a C2S stream by inspecting inf parameter.
+func IsArchiveRequested(inf c2smodel.Info) bool {
+	return inf.Bool(archiveRequestedCtxKey)
+}
+
+// ExtractSentArchiveID returns message sent archive ID by inspecting the passed context.
+func ExtractSentArchiveID(ctx context.Context) string {
+	ret, ok := ctx.Value(sentArchiveIDKey).(string)
+	if ok {
+		return ret
+	}
+	return ""
+}
+
+// ExtractReceivedArchiveID returns message received archive ID by inspecting the passed context.
+func ExtractReceivedArchiveID(ctx context.Context) string {
+	ret, ok := ctx.Value(receivedArchiveIDKey).(string)
+	if ok {
+		return ret
+	}
+	return ""
+}
+
+func formToFilters(fm *xep0004.DataForm) (*archivemodel.Filters, error) {
+	var retVal archivemodel.Filters
+
+	fmType := fm.Fields.ValueForFieldOfType(xep0004.FormType, xep0004.Hidden)
+	if fm.Type != xep0004.Submit || fmType != mamNamespace {
+		return nil, errors.New("unexpected form type value")
+	}
+	if start := fm.Fields.ValueForField("start"); len(start) > 0 {
+		startTm, err := time.Parse(dateTimeFormat, start)
+		if err != nil {
+			return nil, err
+		}
+		retVal.Start = timestamppb.New(startTm)
+	}
+	if end := fm.Fields.ValueForField("end"); len(end) > 0 {
+		endTm, err := time.Parse(dateTimeFormat, end)
+		if err != nil {
+			return nil, err
+		}
+		retVal.End = timestamppb.New(endTm)
+	}
+	if with := fm.Fields.ValueForField("with"); len(with) > 0 {
+		retVal.With = with
+	}
+	if beforeID := fm.Fields.ValueForField("before-id"); len(beforeID) > 0 {
+		retVal.BeforeId = beforeID
+	}
+	if afterID := fm.Fields.ValueForField("after-id"); len(afterID) > 0 {
+		retVal.AfterId = afterID
+	}
+	if ids := fm.Fields.ValuesForField("ids"); len(ids) > 0 {
+		retVal.Ids = ids
+	}
+	return &retVal, nil
+}
+
+func isMessageArchievable(msg *stravaganza.Message) bool {
+	return (msg.IsNormal() || msg.IsChat()) && msg.IsMessageWithBody()
+}
diff --git a/pkg/module/xep0313/mam_test.go b/pkg/module/xep0313/mam_test.go
new file mode 100644
index 000000000..a0f410536
--- /dev/null
+++ b/pkg/module/xep0313/mam_test.go
@@ -0,0 +1,477 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 xep0313
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	kitlog "github.com/go-kit/log"
+	"github.com/jackal-xmpp/stravaganza"
+	"github.com/jackal-xmpp/stravaganza/jid"
+	"github.com/ortuman/jackal/pkg/hook"
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	c2smodel "github.com/ortuman/jackal/pkg/model/c2s"
+	"github.com/ortuman/jackal/pkg/module/xep0004"
+	"github.com/ortuman/jackal/pkg/module/xep0059"
+	"github.com/ortuman/jackal/pkg/router"
+	"github.com/ortuman/jackal/pkg/router/stream"
+	"github.com/ortuman/jackal/pkg/storage/repository"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func TestMam_FormFields(t *testing.T) {
+	// given
+	routerMock := &routerMock{}
+
+	var respStanzas []stravaganza.Stanza
+	routerMock.RouteFunc = func(ctx context.Context, stanza stravaganza.Stanza) ([]jid.JID, error) {
+		respStanzas = append(respStanzas, stanza)
+		return nil, nil
+	}
+	mam := &Mam{
+		router: routerMock,
+		logger: kitlog.NewNopLogger(),
+	}
+
+	iq, _ := stravaganza.NewIQBuilder().
+		WithAttribute(stravaganza.ID, "form1").
+		WithAttribute(stravaganza.Type, stravaganza.GetType).
+		WithAttribute(stravaganza.From, "ortuman@jackal.im/chamber").
+		WithAttribute(stravaganza.To, "ortuman@jackal.im").
+		WithChild(
+			stravaganza.NewBuilder("query").
+				WithAttribute(stravaganza.Namespace, mamNamespace).
+				Build(),
+		).
+		BuildIQ()
+
+	// when
+	_ = mam.ProcessIQ(context.Background(), iq)
+
+	// then
+	require.Len(t, respStanzas, 1)
+	require.Equal(t, "iq", respStanzas[0].Name())
+	require.Equal(t, stravaganza.ResultType, respStanzas[0].Type())
+
+	qChild := respStanzas[0].ChildNamespace("query", mamNamespace)
+	require.NotNil(t, qChild)
+
+	x := qChild.ChildNamespace("x", xep0004.FormNamespace)
+	require.NotNil(t, x)
+
+	form, _ := xep0004.NewFormFromElement(x)
+	require.NotNil(t, form)
+
+	require.Len(t, form.Fields, 7)
+}
+
+func TestMam_Metadata(t *testing.T) {
+	// given
+	routerMock := &routerMock{}
+
+	var respStanzas []stravaganza.Stanza
+	routerMock.RouteFunc = func(ctx context.Context, stanza stravaganza.Stanza) ([]jid.JID, error) {
+		respStanzas = append(respStanzas, stanza)
+		return nil, nil
+	}
+	repMock := &repositoryMock{}
+	repMock.FetchArchiveMetadataFunc = func(ctx context.Context, archiveID string) (*archivemodel.Metadata, error) {
+		return &archivemodel.Metadata{
+			StartId:        "s0",
+			StartTimestamp: "2008-08-22T21:09:04Z",
+			EndId:          "e0",
+			EndTimestamp:   "2020-04-20T14:34:21Z",
+		}, nil
+	}
+	mam := &Mam{
+		rep:    repMock,
+		router: routerMock,
+		logger: kitlog.NewNopLogger(),
+	}
+
+	iq, _ := stravaganza.NewIQBuilder().
+		WithAttribute(stravaganza.ID, "form1").
+		WithAttribute(stravaganza.Type, stravaganza.GetType).
+		WithAttribute(stravaganza.From, "ortuman@jackal.im/chamber").
+		WithAttribute(stravaganza.To, "ortuman@jackal.im").
+		WithChild(
+			stravaganza.NewBuilder("metadata").
+				WithAttribute(stravaganza.Namespace, mamNamespace).
+				Build(),
+		).
+		BuildIQ()
+
+	// when
+	_ = mam.ProcessIQ(context.Background(), iq)
+
+	// then
+	require.Len(t, respStanzas, 1)
+	require.Equal(t, "iq", respStanzas[0].Name())
+	require.Equal(t, stravaganza.ResultType, respStanzas[0].Type())
+
+	metadata := respStanzas[0].ChildNamespace("metadata", mamNamespace)
+	require.NotNil(t, metadata)
+
+	start := metadata.Child("start")
+	require.NotNil(t, start)
+	require.Equal(t, "s0", start.Attribute("id"))
+	require.Equal(t, "2008-08-22T21:09:04Z", start.Attribute("timestamp"))
+
+	end := metadata.Child("end")
+	require.NotNil(t, start)
+	require.Equal(t, "e0", end.Attribute("id"))
+	require.Equal(t, "2020-04-20T14:34:21Z", end.Attribute("timestamp"))
+}
+
+func TestMam_ArchiveMessage(t *testing.T) {
+	// given
+	var archivedMessages []*archivemodel.Message
+
+	txMock := &txMock{}
+	txMock.DeleteArchiveOldestMessagesFunc = func(ctx context.Context, archiveID string, maxElements int) error {
+		return nil
+	}
+	txMock.InsertArchiveMessageFunc = func(ctx context.Context, message *archivemodel.Message) error {
+		archivedMessages = append(archivedMessages, message)
+		return nil
+	}
+
+	repMock := &repositoryMock{}
+	repMock.InTransactionFunc = func(ctx context.Context, f func(ctx context.Context, tx repository.Transaction) error) error {
+		return f(ctx, txMock)
+	}
+
+	hosts := &hostsMock{}
+	hosts.IsLocalHostFunc = func(h string) bool { return h == "jackal.im" }
+
+	hk := hook.NewHooks()
+	mam := &Mam{
+		hk:     hk,
+		hosts:  hosts,
+		rep:    repMock,
+		logger: kitlog.NewNopLogger(),
+	}
+	_ = mam.Start(context.Background())
+	t.Cleanup(func() {
+		_ = mam.Stop(context.Background())
+	})
+
+	msg := testMessageStanzaWithParameters("b0", "ortuman@jackal.im/chamber", "noelia@jackal.im/yard")
+
+	// when
+	execCtx := &hook.ExecutionContext{
+		Info: &hook.C2SStreamInfo{
+			Element: msg,
+		},
+		Context: context.Background(),
+	}
+	_, err := hk.Run(hook.C2SStreamMessageReceived, execCtx)
+	require.NoError(t, err)
+
+	_, err = hk.Run(hook.C2SStreamMessageRouted, execCtx)
+	require.NoError(t, err)
+
+	// then
+	require.NoError(t, err)
+	require.Len(t, archivedMessages, 2)
+
+	require.Equal(t, "ortuman", archivedMessages[0].ArchiveId)
+	require.Equal(t, "noelia", archivedMessages[1].ArchiveId)
+
+	require.Len(t, txMock.DeleteArchiveOldestMessagesCalls(), 2)
+	require.Len(t, txMock.InsertArchiveMessageCalls(), 2)
+
+	require.True(t, len(ExtractSentArchiveID(execCtx.Context)) > 0)
+	require.True(t, len(ExtractReceivedArchiveID(execCtx.Context)) > 0)
+}
+
+func TestMam_SendArchiveMessages(t *testing.T) {
+	// given
+	archiveMessages := []*archivemodel.Message{
+		{
+			ArchiveId: "ortuman",
+			Stamp:     timestamppb.New(time.Date(2022, 01, 01, 00, 00, 00, 00, time.UTC)),
+			FromJid:   "ortuman@jackal.im/chamber",
+			ToJid:     "noelia@jackal.im/yard",
+			Message: testMessageStanzaWithParameters(
+				"b0",
+				"ortuman@jackal.im/chamber",
+				"noelia@jackal.im/yard",
+			).Proto(),
+		},
+		{
+			ArchiveId: "ortuman",
+			Stamp:     timestamppb.New(time.Date(2022, 01, 01, 01, 00, 00, 00, time.UTC)),
+			FromJid:   "noelia@jackal.im/yard",
+			ToJid:     "ortuman@jackal.im/chamber",
+			Message: testMessageStanzaWithParameters(
+				"b1",
+				"noelia@jackal.im/yard",
+				"ortuman@jackal.im/chamber",
+			).Proto(),
+		},
+		{
+			ArchiveId: "ortuman",
+			Stamp:     timestamppb.New(time.Date(2022, 01, 01, 02, 00, 00, 00, time.UTC)),
+			FromJid:   "ortuman@jackal.im/chamber",
+			ToJid:     "noelia@jackal.im/yard",
+			Message: testMessageStanzaWithParameters(
+				"b2",
+				"ortuman@jackal.im/chamber",
+				"noelia@jackal.im/yard",
+			).Proto(),
+		},
+	}
+
+	c2sInf := c2smodel.NewInfoMap()
+
+	stmMock := &c2sStreamMock{}
+	stmMock.SetInfoValueFunc = func(ctx context.Context, k string, val interface{}) error {
+		bVal, ok := val.(bool)
+		if !ok {
+			return errors.New("unexpected value type")
+		}
+		c2sInf.SetBool(k, bVal)
+		return nil
+	}
+
+	c2sRouterMock := &c2sRouterMock{}
+	c2sRouterMock.LocalStreamFunc = func(username string, resource string) (stream.C2S, error) {
+		return stmMock, nil
+	}
+
+	routerMock := &routerMock{}
+
+	var respStanzas []stravaganza.Stanza
+	routerMock.RouteFunc = func(ctx context.Context, stanza stravaganza.Stanza) ([]jid.JID, error) {
+		respStanzas = append(respStanzas, stanza)
+		return nil, nil
+	}
+	routerMock.C2SFunc = func() router.C2SRouter {
+		return c2sRouterMock
+	}
+
+	repMock := &repositoryMock{}
+	repMock.FetchArchiveMessagesFunc = func(ctx context.Context, f *archivemodel.Filters, archiveID string) ([]*archivemodel.Message, error) {
+		return archiveMessages, nil
+	}
+
+	mam := &Mam{
+		rep:    repMock,
+		router: routerMock,
+		logger: kitlog.NewNopLogger(),
+	}
+
+	iq, _ := stravaganza.NewIQBuilder().
+		WithAttribute(stravaganza.ID, "ortuman1").
+		WithAttribute(stravaganza.Type, stravaganza.SetType).
+		WithAttribute(stravaganza.From, "ortuman@jackal.im/chamber").
+		WithAttribute(stravaganza.To, "ortuman@jackal.im").
+		WithChild(
+			stravaganza.NewBuilder("query").
+				WithAttribute(stravaganza.Namespace, mamNamespace).
+				Build(),
+		).
+		BuildIQ()
+
+	// when
+	_ = mam.ProcessIQ(context.Background(), iq)
+
+	// then
+	require.Len(t, respStanzas, 4) // 3 messages + result iq
+
+	require.Equal(t, stravaganza.MessageName, respStanzas[0].Name())
+	require.Equal(t, stravaganza.MessageName, respStanzas[1].Name())
+	require.Equal(t, stravaganza.MessageName, respStanzas[2].Name())
+	require.Equal(t, stravaganza.IQName, respStanzas[3].Name())
+
+	iqRes := respStanzas[3]
+	require.Equal(t, stravaganza.ResultType, iqRes.Type())
+
+	finElem := iqRes.ChildNamespace("fin", mamNamespace)
+	require.NotNil(t, finElem)
+
+	rsmRes := finElem.ChildNamespace("set", xep0059.RSMNamespace)
+	require.NotNil(t, rsmRes)
+
+	count := rsmRes.Child("count")
+	require.NotNil(t, count)
+	require.Equal(t, "3", count.Text())
+
+	require.Len(t, stmMock.SetInfoValueCalls(), 1)
+	require.True(t, IsArchiveRequested(c2sInf))
+}
+
+func TestMam_Forbidden(t *testing.T) {
+	routerMock := &routerMock{}
+
+	var respStanzas []stravaganza.Stanza
+	routerMock.RouteFunc = func(ctx context.Context, stanza stravaganza.Stanza) ([]jid.JID, error) {
+		respStanzas = append(respStanzas, stanza)
+		return nil, nil
+	}
+
+	repMock := &repositoryMock{}
+
+	mam := &Mam{
+		rep:    repMock,
+		router: routerMock,
+		logger: kitlog.NewNopLogger(),
+	}
+
+	iq, _ := stravaganza.NewIQBuilder().
+		WithAttribute(stravaganza.ID, "ortuman1").
+		WithAttribute(stravaganza.Type, stravaganza.SetType).
+		WithAttribute(stravaganza.From, "noelia@jackal.im/chamber").
+		WithAttribute(stravaganza.To, "ortuman@jackal.im").
+		WithChild(
+			stravaganza.NewBuilder("query").
+				WithAttribute(stravaganza.Namespace, mamNamespace).
+				Build(),
+		).
+		BuildIQ()
+
+	// when
+	_ = mam.ProcessIQ(context.Background(), iq)
+
+	require.Len(t, respStanzas, 1)
+	require.Equal(t, stravaganza.ErrorType, respStanzas[0].Attribute(stravaganza.Type))
+}
+
+func TestMam_DeleteArchive(t *testing.T) {
+	// given
+	var deletedArchiveID string
+
+	repMock := &repositoryMock{}
+	repMock.DeleteArchiveFunc = func(ctx context.Context, archiveID string) error {
+		deletedArchiveID = archiveID
+		return nil
+	}
+
+	hosts := &hostsMock{}
+	hosts.IsLocalHostFunc = func(h string) bool { return h == "jackal.im" }
+
+	hk := hook.NewHooks()
+	mam := &Mam{
+		hk:     hk,
+		hosts:  hosts,
+		rep:    repMock,
+		logger: kitlog.NewNopLogger(),
+	}
+	_ = mam.Start(context.Background())
+	t.Cleanup(func() {
+		_ = mam.Stop(context.Background())
+	})
+
+	// when
+	_, err := hk.Run(hook.UserDeleted, &hook.ExecutionContext{
+		Info: &hook.UserInfo{
+			Username: "ortuman",
+		},
+		Context: context.Background(),
+	},
+	)
+
+	// then
+	require.NoError(t, err)
+	require.Len(t, repMock.DeleteArchiveCalls(), 1)
+
+	require.Equal(t, "ortuman", deletedArchiveID)
+}
+
+func TestMam_FormToFields(t *testing.T) {
+	tcs := map[string]struct {
+		form    *xep0004.DataForm
+		filters *archivemodel.Filters
+	}{
+		"by jid": {
+			form: &xep0004.DataForm{
+				Type: xep0004.Submit,
+				Fields: []xep0004.Field{
+					{Var: xep0004.FormType, Type: xep0004.Hidden, Values: []string{mamNamespace}},
+					{Var: "with", Values: []string{"juliet@capulet.lit"}},
+				},
+			},
+			filters: &archivemodel.Filters{
+				With: "juliet@capulet.lit",
+			},
+		},
+		"time received": {
+			form: &xep0004.DataForm{
+				Type: xep0004.Submit,
+				Fields: []xep0004.Field{
+					{Var: xep0004.FormType, Type: xep0004.Hidden, Values: []string{mamNamespace}},
+					{Var: "start", Values: []string{"2010-06-07T00:00:00Z"}},
+					{Var: "end", Values: []string{"2010-07-07T13:23:54Z"}},
+				},
+			},
+			filters: &archivemodel.Filters{
+				Start: timestamppb.New(time.Date(2010, 06, 07, 00, 00, 00, 00, time.UTC)),
+				End:   timestamppb.New(time.Date(2010, 07, 07, 13, 23, 54, 00, time.UTC)),
+			},
+		},
+		"after/before id": {
+			form: &xep0004.DataForm{
+				Type: xep0004.Submit,
+				Fields: []xep0004.Field{
+					{Var: xep0004.FormType, Type: xep0004.Hidden, Values: []string{mamNamespace}},
+					{Var: "after-id", Values: []string{"28482-98726-73623"}},
+					{Var: "before-id", Values: []string{"09af3-cc343-b409f"}},
+				},
+			},
+			filters: &archivemodel.Filters{
+				AfterId:  "28482-98726-73623",
+				BeforeId: "09af3-cc343-b409f",
+			},
+		},
+		"ids": {
+			form: &xep0004.DataForm{
+				Type: xep0004.Submit,
+				Fields: []xep0004.Field{
+					{Var: xep0004.FormType, Type: xep0004.Hidden, Values: []string{mamNamespace}},
+					{Var: "ids", Values: []string{"28482-98726-73623", "09af3-cc343-b409f"}},
+				},
+			},
+			filters: &archivemodel.Filters{
+				Ids: []string{"28482-98726-73623", "09af3-cc343-b409f"},
+			},
+		},
+	}
+	for tn, tc := range tcs {
+		t.Run(tn, func(t *testing.T) {
+			filters, err := formToFilters(tc.form)
+
+			require.NoError(t, err)
+			require.Equal(t, tc.filters.String(), filters.String())
+		})
+	}
+}
+
+func testMessageStanzaWithParameters(body, from, to string) *stravaganza.Message {
+	b := stravaganza.NewMessageBuilder()
+	b.WithAttribute("from", from)
+	b.WithAttribute("to", to)
+	b.WithChild(
+		stravaganza.NewBuilder("body").
+			WithText(body).
+			Build(),
+	)
+	msg, _ := b.BuildMessage()
+	return msg
+}
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 1944ecf2a..beda06ab4 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -72,7 +72,7 @@ type C2SRouter interface {
 	Unregister(stm stream.C2S) error
 
 	// LocalStream returns local instance stream.
-	LocalStream(username, resource string) stream.C2S
+	LocalStream(username, resource string) (stream.C2S, error)
 
 	// Start starts C2S router subsystem.
 	Start(ctx context.Context) error
diff --git a/pkg/s2s/in.go b/pkg/s2s/in.go
index 695c8d202..389c121da 100644
--- a/pkg/s2s/in.go
+++ b/pkg/s2s/in.go
@@ -17,6 +17,7 @@ package s2s
 import (
 	"context"
 	"crypto/tls"
+	"errors"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -401,7 +402,7 @@ func (s *inS2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	if !ok {
 		return nil
 	}
-	_, err = s.router.Route(ctx, outIQ)
+	targets, err := s.router.Route(ctx, outIQ)
 	switch err {
 	case router.ErrResourceNotFound:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, iq).Element())
@@ -412,11 +413,12 @@ func (s *inS2S) processIQ(ctx context.Context, iq *stravaganza.IQ) error {
 	case router.ErrRemoteServerTimeout:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.RemoteServerTimeout, iq).Element())
 
-	case nil:
+	case nil, router.ErrUserNotAvailable:
 		_, err = s.runHook(ctx, hook.S2SInStreamIQRouted, &hook.S2SStreamInfo{
 			ID:      s.ID().String(),
 			Sender:  s.sender,
 			Target:  s.target,
+			Targets: targets,
 			Element: iq,
 		})
 		return err
@@ -456,7 +458,7 @@ sendMsg:
 	if !ok {
 		return nil
 	}
-	_, err = s.router.Route(ctx, outMsg)
+	targets, err := s.router.Route(ctx, outMsg)
 	switch err {
 	case router.ErrResourceNotFound:
 		// treat the stanza as if it were addressed to <node@domain>
@@ -475,19 +477,25 @@ sendMsg:
 	case router.ErrRemoteServerTimeout:
 		return s.sendElement(ctx, stanzaerror.E(stanzaerror.RemoteServerTimeout, message).Element())
 
-	case router.ErrUserNotAvailable:
-		return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, message).Element())
-
-	case nil:
-		_, err = s.runHook(ctx, hook.S2SInStreamMessageRouted, &hook.S2SStreamInfo{
+	case nil, router.ErrUserNotAvailable:
+		halted, hErr := s.runHook(ctx, hook.S2SInStreamMessageRouted, &hook.S2SStreamInfo{
 			ID:      s.ID().String(),
 			Sender:  s.sender,
 			Target:  s.target,
+			Targets: targets,
 			Element: msg,
 		})
+		if halted {
+			return nil
+		}
+		if errors.Is(err, router.ErrUserNotAvailable) {
+			return s.sendElement(ctx, stanzaerror.E(stanzaerror.ServiceUnavailable, message).Element())
+		}
+		return hErr
+
+	default:
 		return err
 	}
-	return nil
 }
 
 func (s *inS2S) processPresence(ctx context.Context, presence *stravaganza.Presence) error {
@@ -520,13 +528,14 @@ func (s *inS2S) processPresence(ctx context.Context, presence *stravaganza.Prese
 		if !ok {
 			return nil
 		}
-		_, err = s.router.Route(ctx, outPr)
+		targets, err := s.router.Route(ctx, outPr)
 		switch err {
-		case nil:
+		case nil, router.ErrUserNotAvailable:
 			_, err := s.runHook(ctx, hook.S2SInStreamPresenceRouted, &hook.S2SStreamInfo{
 				ID:      s.ID().String(),
 				Sender:  s.sender,
 				Target:  s.target,
+				Targets: targets,
 				Element: presence,
 			})
 			return err
diff --git a/pkg/storage/boltdb/archive.go b/pkg/storage/boltdb/archive.go
new file mode 100644
index 000000000..093235dc7
--- /dev/null
+++ b/pkg/storage/boltdb/archive.go
@@ -0,0 +1,274 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 boltdb
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/golang/protobuf/proto"
+	"github.com/jackal-xmpp/stravaganza/jid"
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	bolt "go.etcd.io/bbolt"
+)
+
+const archiveStampFormat = "2006-01-02T15:04:05Z"
+
+type boltDBArchiveRep struct {
+	tx *bolt.Tx
+}
+
+func newArchiveRep(tx *bolt.Tx) *boltDBArchiveRep {
+	return &boltDBArchiveRep{tx: tx}
+}
+
+func (r *boltDBArchiveRep) InsertArchiveMessage(_ context.Context, message *archivemodel.Message) error {
+	op := insertSeqOp{
+		tx:     r.tx,
+		bucket: archiveBucket(message.ArchiveId),
+		obj:    message,
+	}
+	return op.do()
+}
+
+func (r *boltDBArchiveRep) FetchArchiveMetadata(_ context.Context, archiveID string) (metadata *archivemodel.Metadata, err error) {
+	bucketID := archiveBucket(archiveID)
+
+	b := r.tx.Bucket([]byte(bucketID))
+	if b == nil {
+		return nil, nil
+	}
+	var retVal archivemodel.Metadata
+
+	c := b.Cursor()
+	_, val := c.First()
+
+	var msg archivemodel.Message
+	if err := proto.Unmarshal(val, &msg); err != nil {
+		return nil, err
+	}
+	retVal.StartId = msg.Id
+	retVal.StartTimestamp = msg.Stamp.AsTime().UTC().Format(archiveStampFormat)
+
+	_, val = c.Last()
+	if err := proto.Unmarshal(val, &msg); err != nil {
+		return nil, err
+	}
+	retVal.EndId = msg.Id
+	retVal.EndTimestamp = msg.Stamp.AsTime().UTC().Format(archiveStampFormat)
+
+	return &retVal, nil
+}
+
+func (r *boltDBArchiveRep) FetchArchiveMessages(_ context.Context, f *archivemodel.Filters, archiveID string) ([]*archivemodel.Message, error) {
+	var retVal []*archivemodel.Message
+
+	op := iterKeysOp{
+		tx:     r.tx,
+		bucket: archiveBucket(archiveID),
+		iterFn: func(k, b []byte) error {
+			var msg archivemodel.Message
+			if err := proto.Unmarshal(b, &msg); err != nil {
+				return err
+			}
+			retVal = append(retVal, &msg)
+			return nil
+		},
+	}
+	if err := op.do(); err != nil {
+		return nil, err
+	}
+	return applyFilters(retVal, f)
+}
+
+func (r *boltDBArchiveRep) DeleteArchiveOldestMessages(_ context.Context, archiveID string, maxElements int) error {
+	bucketID := archiveBucket(archiveID)
+
+	b := r.tx.Bucket([]byte(bucketID))
+	if b == nil {
+		return nil
+	}
+	// count items
+	var count int
+
+	c := b.Cursor()
+	for k, _ := c.First(); k != nil; k, _ = c.Next() {
+		count++
+	}
+	if count < maxElements {
+		return nil
+	}
+	// store old value keys
+	var oldKeys [][]byte
+
+	c = b.Cursor()
+	for k, _ := c.First(); k != nil; k, _ = c.Next() {
+		if count <= maxElements {
+			break
+		}
+		count--
+		oldKeys = append(oldKeys, k)
+	}
+	// delete old values
+	for _, k := range oldKeys {
+		if err := b.Delete(k); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *boltDBArchiveRep) DeleteArchive(_ context.Context, archiveID string) error {
+	op := delBucketOp{
+		tx:     r.tx,
+		bucket: archiveBucket(archiveID),
+	}
+	return op.do()
+}
+
+func archiveBucket(archiveID string) string {
+	return fmt.Sprintf("archive:%s", archiveID)
+}
+
+// InsertArchiveMessage inserts a new message element into an archive queue.
+func (r *Repository) InsertArchiveMessage(ctx context.Context, message *archivemodel.Message) error {
+	return r.db.Update(func(tx *bolt.Tx) error {
+		return newArchiveRep(tx).InsertArchiveMessage(ctx, message)
+	})
+}
+
+// FetchArchiveMetadata returns the metadata value associated to an archive.
+func (r *Repository) FetchArchiveMetadata(ctx context.Context, archiveID string) (metadata *archivemodel.Metadata, err error) {
+	err = r.db.View(func(tx *bolt.Tx) error {
+		metadata, err = newArchiveRep(tx).FetchArchiveMetadata(ctx, archiveID)
+		return err
+	})
+	return
+}
+
+// FetchArchiveMessages fetches archive asscociated messages applying the passed f filters.
+func (r *Repository) FetchArchiveMessages(ctx context.Context, f *archivemodel.Filters, archiveID string) (messages []*archivemodel.Message, err error) {
+	err = r.db.View(func(tx *bolt.Tx) error {
+		messages, err = newArchiveRep(tx).FetchArchiveMessages(ctx, f, archiveID)
+		return err
+	})
+	return
+}
+
+// DeleteArchiveOldestMessages trims archive oldest messages up to a maxElements total count.
+func (r *Repository) DeleteArchiveOldestMessages(ctx context.Context, archiveID string, maxElements int) error {
+	return r.db.Update(func(tx *bolt.Tx) error {
+		return newArchiveRep(tx).DeleteArchiveOldestMessages(ctx, archiveID, maxElements)
+	})
+}
+
+// DeleteArchive clears an archive queue.
+func (r *Repository) DeleteArchive(ctx context.Context, archiveID string) error {
+	return r.db.Update(func(tx *bolt.Tx) error {
+		return newArchiveRep(tx).DeleteArchive(ctx, archiveID)
+	})
+}
+
+func applyFilters(messages []*archivemodel.Message, f *archivemodel.Filters) ([]*archivemodel.Message, error) {
+	retVal := messages
+
+	// filtering by JID
+	if len(f.With) > 0 {
+		jd, err := jid.NewWithString(f.With, false)
+		if err != nil {
+			return nil, err
+		}
+		var filtered []*archivemodel.Message
+		for _, msg := range retVal {
+			var matches bool
+
+			switch {
+			case jd.IsFull():
+				matches = msg.FromJid == jd.String() || msg.ToJid == jd.String()
+
+			default:
+				fromJID, _ := jid.NewWithString(msg.FromJid, true)
+				toJID, _ := jid.NewWithString(msg.ToJid, true)
+				matches = fromJID.MatchesWithOptions(jd, jid.MatchesBare) || toJID.MatchesWithOptions(jd, jid.MatchesBare)
+			}
+			if matches {
+				filtered = append(filtered, msg)
+			}
+		}
+		retVal = filtered
+	}
+
+	// filtering by id
+	if len(f.Ids) > 0 {
+		idsMap := map[string]struct{}{}
+		for _, id := range f.Ids {
+			idsMap[id] = struct{}{}
+		}
+		var filtered []*archivemodel.Message
+		for _, msg := range retVal {
+			_, ok := idsMap[msg.Id]
+			if !ok {
+				continue
+			}
+			filtered = append(filtered, msg)
+		}
+		retVal = filtered
+
+	} else {
+		if len(f.BeforeId) > 0 {
+			for i, msg := range retVal {
+				if msg.Id != f.BeforeId {
+					continue
+				}
+				retVal = retVal[:i]
+				break
+			}
+		}
+		if len(f.AfterId) > 0 {
+			for i, msg := range retVal {
+				if msg.Id != f.AfterId {
+					continue
+				}
+				retVal = retVal[i+1:]
+				break
+			}
+		}
+	}
+
+	// filtering by timestamp
+	if f.Start != nil {
+		startTm := f.Start.AsTime()
+		for i, msg := range retVal {
+			stampTm := msg.Stamp.AsTime()
+			if !stampTm.After(startTm) {
+				continue
+			}
+			retVal = retVal[i:]
+			break
+		}
+	}
+	if f.End != nil {
+		endTm := f.End.AsTime()
+		for i, msg := range retVal {
+			stampTm := msg.Stamp.AsTime()
+			if stampTm.Before(endTm) {
+				continue
+			}
+			retVal = retVal[:i]
+			break
+		}
+	}
+	return retVal, nil
+}
diff --git a/pkg/storage/boltdb/archive_test.go b/pkg/storage/boltdb/archive_test.go
new file mode 100644
index 000000000..620826715
--- /dev/null
+++ b/pkg/storage/boltdb/archive_test.go
@@ -0,0 +1,292 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 boltdb
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	"github.com/stretchr/testify/require"
+	bolt "go.etcd.io/bbolt"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func TestBoltDB_InsertArchiveMessage(t *testing.T) {
+	t.Parallel()
+
+	db := setupDB(t)
+	t.Cleanup(func() { cleanUp(db) })
+
+	err := db.Update(func(tx *bolt.Tx) error {
+		rep := boltDBArchiveRep{tx: tx}
+
+		m0 := testMessageStanza()
+
+		err := rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Message:   m0.Proto(),
+		})
+		require.NoError(t, err)
+
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+func TestBoltDB_FetchArchiveMetadata(t *testing.T) {
+	t.Parallel()
+
+	db := setupDB(t)
+	t.Cleanup(func() { cleanUp(db) })
+
+	err := db.Update(func(tx *bolt.Tx) error {
+		rep := boltDBArchiveRep{tx: tx}
+
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
+		m2 := testMessageStanza()
+
+		now0 := time.Now()
+		now1 := now0.Add(time.Hour)
+		now2 := now1.Add(time.Hour)
+
+		err := rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Id:        "id0",
+			Message:   m0.Proto(),
+			Stamp:     timestamppb.New(now0),
+		})
+		require.NoError(t, err)
+
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Id:        "id1",
+			Message:   m1.Proto(),
+			Stamp:     timestamppb.New(now1),
+		})
+		require.NoError(t, err)
+
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Id:        "id2",
+			Message:   m2.Proto(),
+			Stamp:     timestamppb.New(now2),
+		})
+		require.NoError(t, err)
+
+		metadata, err := rep.FetchArchiveMetadata(context.Background(), "a1234")
+		require.NoError(t, err)
+
+		require.Equal(t, "id0", metadata.StartId)
+		require.Equal(t, now0.UTC().Format(archiveStampFormat), metadata.StartTimestamp)
+		require.Equal(t, "id2", metadata.EndId)
+		require.Equal(t, now2.UTC().Format(archiveStampFormat), metadata.EndTimestamp)
+
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+func TestBoltDB_DeleteArchive(t *testing.T) {
+	t.Parallel()
+
+	db := setupDB(t)
+	t.Cleanup(func() { cleanUp(db) })
+
+	err := db.Update(func(tx *bolt.Tx) error {
+		rep := boltDBArchiveRep{tx: tx}
+
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
+		m2 := testMessageStanza()
+
+		err := rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{ArchiveId: "a1234", Message: m0.Proto()})
+		require.NoError(t, err)
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{ArchiveId: "a1234", Message: m1.Proto()})
+		require.NoError(t, err)
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{ArchiveId: "a1234", Message: m2.Proto()})
+		require.NoError(t, err)
+
+		require.Equal(t, 3, countBucketElements(t, tx, archiveBucket("a1234")))
+
+		require.NoError(t, rep.DeleteArchive(context.Background(), "a1234"))
+
+		require.Equal(t, 0, countBucketElements(t, tx, archiveBucket("a1234")))
+
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+func TestBoltDB_DeleteArchiveOldestMessages(t *testing.T) {
+	t.Parallel()
+
+	db := setupDB(t)
+	t.Cleanup(func() { cleanUp(db) })
+
+	err := db.Update(func(tx *bolt.Tx) error {
+		rep := boltDBArchiveRep{tx: tx}
+
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
+		m2 := testMessageStanza()
+
+		err := rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Message:   m0.Proto(),
+		})
+		require.NoError(t, err)
+
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Message:   m1.Proto(),
+		})
+		require.NoError(t, err)
+
+		err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+			ArchiveId: "a1234",
+			Message:   m2.Proto(),
+		})
+		require.NoError(t, err)
+
+		require.Equal(t, 3, countBucketElements(t, tx, archiveBucket("a1234")))
+
+		err = rep.DeleteArchiveOldestMessages(context.Background(), "a1234", 2)
+		require.NoError(t, err)
+
+		require.Equal(t, 2, countBucketElements(t, tx, archiveBucket("a1234")))
+
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+func TestBoltDB_FetchArchiveMessages(t *testing.T) {
+	tcs := map[string]struct {
+		filters           *archivemodel.Filters
+		expectedResultIDs []string
+	}{
+		"filtering by jid": {
+			filters: &archivemodel.Filters{
+				With: "noelia@jackal.im",
+			},
+			expectedResultIDs: []string{"m0", "m1", "m3"},
+		},
+		"filtering by full jid": {
+			filters: &archivemodel.Filters{
+				With: "ortuman@jackal.im/firstwitch",
+			},
+			expectedResultIDs: []string{"m2"},
+		},
+		"filtering by ids": {
+			filters: &archivemodel.Filters{
+				Ids: []string{"m0", "m2"},
+			},
+			expectedResultIDs: []string{"m0", "m2"},
+		},
+		"filtering by after id": {
+			filters: &archivemodel.Filters{
+				AfterId: "m1",
+			},
+			expectedResultIDs: []string{"m2", "m3"},
+		},
+		"filtering by before id": {
+			filters: &archivemodel.Filters{
+				BeforeId: "m2",
+			},
+			expectedResultIDs: []string{"m0", "m1"},
+		},
+		"filtering by start": {
+			filters: &archivemodel.Filters{
+				Start: timestamppb.New(time.Date(2022, 01, 02, 00, 00, 00, 00, time.UTC)),
+			},
+			expectedResultIDs: []string{"m2", "m3"},
+		},
+		"filtering by end": {
+			filters: &archivemodel.Filters{
+				End: timestamppb.New(time.Date(2022, 01, 02, 00, 00, 00, 00, time.UTC)),
+			},
+			expectedResultIDs: []string{"m0"},
+		},
+	}
+	for tn, tc := range tcs {
+		t.Run(tn, func(t *testing.T) {
+			db := setupDB(t)
+			t.Cleanup(func() { cleanUp(db) })
+
+			err := db.Update(func(tx *bolt.Tx) error {
+				rep := boltDBArchiveRep{tx: tx}
+
+				m0 := testMessageStanzaWithParameters("b0", "noelia@jackal.im/yard", "ortuman@jackal.im/chamber")
+				m1 := testMessageStanzaWithParameters("b1", "noelia@jackal.im/orchard", "ortuman@jackal.im/balcony")
+				m2 := testMessageStanzaWithParameters("b2", "witch1@jackal.im/yard", "ortuman@jackal.im/firstwitch")
+				m3 := testMessageStanzaWithParameters("b3", "witch2@jackal.im/yard", "noelia@jackal.im/garden")
+
+				err := rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+					ArchiveId: "a1234",
+					Id:        "m0",
+					FromJid:   "noelia@jackal.im/yard",
+					ToJid:     "ortuman@jackal.im/chamber",
+					Stamp:     timestamppb.New(time.Date(2022, 01, 01, 00, 00, 00, 00, time.UTC)),
+					Message:   m0.Proto(),
+				})
+				require.NoError(t, err)
+
+				err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+					ArchiveId: "a1234",
+					Id:        "m1",
+					FromJid:   "noelia@jackal.im/orchard",
+					ToJid:     "ortuman@jackal.im/balcony",
+					Stamp:     timestamppb.New(time.Date(2022, 01, 02, 00, 00, 00, 00, time.UTC)),
+					Message:   m1.Proto(),
+				})
+				require.NoError(t, err)
+
+				err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+					ArchiveId: "a1234",
+					Id:        "m2",
+					FromJid:   "witch1@jackal.im/yard",
+					ToJid:     "ortuman@jackal.im/firstwitch",
+					Stamp:     timestamppb.New(time.Date(2022, 01, 03, 00, 00, 00, 00, time.UTC)),
+					Message:   m2.Proto(),
+				})
+				require.NoError(t, err)
+
+				err = rep.InsertArchiveMessage(context.Background(), &archivemodel.Message{
+					ArchiveId: "a1234",
+					Id:        "m3",
+					FromJid:   "witch2@jackal.im/yard",
+					ToJid:     "noelia@jackal.im/garden",
+					Stamp:     timestamppb.New(time.Date(2022, 01, 04, 00, 00, 00, 00, time.UTC)),
+					Message:   m3.Proto(),
+				})
+				require.NoError(t, err)
+
+				messages, err := rep.FetchArchiveMessages(context.Background(), tc.filters, "a1234")
+				require.NoError(t, err)
+
+				var resultIDs []string
+				for _, msg := range messages {
+					resultIDs = append(resultIDs, msg.Id)
+				}
+				require.ElementsMatch(t, tc.expectedResultIDs, resultIDs)
+				return nil
+			})
+			require.NoError(t, err)
+		})
+	}
+}
diff --git a/pkg/storage/boltdb/offline_test.go b/pkg/storage/boltdb/offline_test.go
index cc9955ad3..dff8cbbc0 100644
--- a/pkg/storage/boltdb/offline_test.go
+++ b/pkg/storage/boltdb/offline_test.go
@@ -31,8 +31,8 @@ func TestBoltDB_InsertAndFetchOfflineMessages(t *testing.T) {
 	err := db.Update(func(tx *bolt.Tx) error {
 		rep := boltDBOfflineRep{tx: tx}
 
-		m0 := testMessageStanza("message 0")
-		m1 := testMessageStanza("message 1")
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
 
 		err := rep.InsertOfflineMessage(context.Background(), m0, "ortuman")
 		require.NoError(t, err)
@@ -45,8 +45,6 @@ func TestBoltDB_InsertAndFetchOfflineMessages(t *testing.T) {
 
 		require.Len(t, messages, 2)
 
-		require.Equal(t, "message 0", messages[0].Child("body").Text())
-		require.Equal(t, "message 1", messages[1].Child("body").Text())
 		return nil
 	})
 	require.NoError(t, err)
@@ -61,8 +59,8 @@ func TestBoltDB_CountOfflineMessages(t *testing.T) {
 	err := db.Update(func(tx *bolt.Tx) error {
 		rep := boltDBOfflineRep{tx: tx}
 
-		m0 := testMessageStanza("message 0")
-		m1 := testMessageStanza("message 1")
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
 
 		err := rep.InsertOfflineMessage(context.Background(), m0, "ortuman")
 		require.NoError(t, err)
@@ -88,8 +86,8 @@ func TestBoltDB_DeleteOfflineMessages(t *testing.T) {
 	err := db.Update(func(tx *bolt.Tx) error {
 		rep := boltDBOfflineRep{tx: tx}
 
-		m0 := testMessageStanza("message 0")
-		m1 := testMessageStanza("message 1")
+		m0 := testMessageStanza()
+		m1 := testMessageStanza()
 
 		err := rep.InsertOfflineMessage(context.Background(), m0, "ortuman")
 		require.NoError(t, err)
diff --git a/pkg/storage/boltdb/op.go b/pkg/storage/boltdb/op.go
index f92bd3257..dac939cbf 100644
--- a/pkg/storage/boltdb/op.go
+++ b/pkg/storage/boltdb/op.go
@@ -60,8 +60,7 @@ func (op insertSeqOp) do() error {
 	if err != nil {
 		return err
 	}
-	k := fmt.Sprintf("%d", seq)
-	return b.Put([]byte(k), p)
+	return b.Put([]byte(fmt.Sprintf("%d", seq)), p)
 }
 
 type delBucketOp struct {
diff --git a/pkg/storage/boltdb/repository.go b/pkg/storage/boltdb/repository.go
index aaa9faa58..dedf3dcaf 100644
--- a/pkg/storage/boltdb/repository.go
+++ b/pkg/storage/boltdb/repository.go
@@ -39,6 +39,7 @@ type Repository struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 
 	cfg Config
diff --git a/pkg/storage/boltdb/repository_test.go b/pkg/storage/boltdb/repository_test.go
new file mode 100644
index 000000000..6b576966c
--- /dev/null
+++ b/pkg/storage/boltdb/repository_test.go
@@ -0,0 +1,36 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 boltdb
+
+import (
+	"testing"
+
+	bolt "go.etcd.io/bbolt"
+)
+
+func countBucketElements(t *testing.T, tx *bolt.Tx, bucket string) int {
+	t.Helper()
+
+	b := tx.Bucket([]byte(bucket))
+	if b == nil {
+		return 0
+	}
+	var count int
+	c := b.Cursor()
+	for k, _ := c.First(); k != nil; k, _ = c.Next() {
+		count++
+	}
+	return count
+}
diff --git a/pkg/storage/boltdb/tx.go b/pkg/storage/boltdb/tx.go
index 2d7cbc29e..8988ffeb1 100644
--- a/pkg/storage/boltdb/tx.go
+++ b/pkg/storage/boltdb/tx.go
@@ -28,6 +28,7 @@ type repTx struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 }
 
@@ -41,6 +42,7 @@ func newRepTx(tx *bolt.Tx) *repTx {
 		Private:      newPrivateRep(tx),
 		Roster:       newRosterRep(tx),
 		VCard:        newVCardRep(tx),
+		Archive:      newArchiveRep(tx),
 		Locker:       newLockerRep(),
 	}
 }
diff --git a/pkg/storage/boltdb/util_test.go b/pkg/storage/boltdb/util_test.go
index 02a9ef18d..04339c9f8 100644
--- a/pkg/storage/boltdb/util_test.go
+++ b/pkg/storage/boltdb/util_test.go
@@ -20,7 +20,6 @@ import (
 	"testing"
 
 	"github.com/jackal-xmpp/stravaganza"
-
 	bolt "go.etcd.io/bbolt"
 )
 
@@ -41,10 +40,23 @@ func cleanUp(db *bolt.DB) {
 	_ = os.RemoveAll(dbPath)
 }
 
-func testMessageStanza(body string) *stravaganza.Message {
+func testMessageStanza() *stravaganza.Message {
 	b := stravaganza.NewMessageBuilder()
 	b.WithAttribute("from", "noelia@jackal.im/yard")
 	b.WithAttribute("to", "ortuman@jackal.im/balcony")
+	b.WithChild(
+		stravaganza.NewBuilder("body").
+			WithText("Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.").
+			Build(),
+	)
+	msg, _ := b.BuildMessage()
+	return msg
+}
+
+func testMessageStanzaWithParameters(body, from, to string) *stravaganza.Message {
+	b := stravaganza.NewMessageBuilder()
+	b.WithAttribute("from", from)
+	b.WithAttribute("to", to)
 	b.WithChild(
 		stravaganza.NewBuilder("body").
 			WithText(body).
diff --git a/pkg/storage/cached/cached.go b/pkg/storage/cached/cached.go
index 5a150b395..798fe99a4 100644
--- a/pkg/storage/cached/cached.go
+++ b/pkg/storage/cached/cached.go
@@ -68,6 +68,7 @@ type CachedRepository struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 
 	rep repository.Repository
@@ -91,6 +92,7 @@ func New(cfg Config, rep repository.Repository, logger kitlog.Logger) (repositor
 		BlockList:    &cachedBlockListRep{c: c, rep: rep, logger: logger},
 		Roster:       &cachedRosterRep{c: c, rep: rep, logger: logger},
 		VCard:        &cachedVCardRep{c: c, rep: rep, logger: logger},
+		Archive:      rep,
 		Offline:      rep,
 		Locker:       rep,
 		rep:          rep,
diff --git a/pkg/storage/cached/tx.go b/pkg/storage/cached/tx.go
index 5c2084a01..230af7c7d 100644
--- a/pkg/storage/cached/tx.go
+++ b/pkg/storage/cached/tx.go
@@ -27,6 +27,7 @@ type cachedTx struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 }
 
@@ -39,6 +40,7 @@ func newCacheTx(c Cache, tx repository.Transaction) *cachedTx {
 		BlockList:    &cachedBlockListRep{c: c, rep: tx},
 		Roster:       &cachedRosterRep{c: c, rep: tx},
 		VCard:        &cachedVCardRep{c: c, rep: tx},
+		Archive:      tx,
 		Offline:      tx,
 		Locker:       tx,
 	}
diff --git a/pkg/storage/measured/archive.go b/pkg/storage/measured/archive.go
new file mode 100644
index 000000000..c44b4fd66
--- /dev/null
+++ b/pkg/storage/measured/archive.go
@@ -0,0 +1,63 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 measuredrepository
+
+import (
+	"context"
+	"time"
+
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	"github.com/ortuman/jackal/pkg/storage/repository"
+)
+
+type measuredArchiveRep struct {
+	rep  repository.Archive
+	inTx bool
+}
+
+func (m *measuredArchiveRep) InsertArchiveMessage(ctx context.Context, message *archivemodel.Message) error {
+	t0 := time.Now()
+	err := m.rep.InsertArchiveMessage(ctx, message)
+	reportOpMetric(upsertOp, time.Since(t0).Seconds(), err == nil, m.inTx)
+	return err
+}
+
+func (m *measuredArchiveRep) FetchArchiveMetadata(ctx context.Context, archiveID string) (metadata *archivemodel.Metadata, err error) {
+	t0 := time.Now()
+	metadata, err = m.rep.FetchArchiveMetadata(ctx, archiveID)
+	reportOpMetric(fetchOp, time.Since(t0).Seconds(), err == nil, m.inTx)
+	return
+}
+
+func (m *measuredArchiveRep) FetchArchiveMessages(ctx context.Context, f *archivemodel.Filters, archiveID string) (messages []*archivemodel.Message, err error) {
+	t0 := time.Now()
+	messages, err = m.rep.FetchArchiveMessages(ctx, f, archiveID)
+	reportOpMetric(fetchOp, time.Since(t0).Seconds(), err == nil, m.inTx)
+	return
+}
+
+func (m *measuredArchiveRep) DeleteArchiveOldestMessages(ctx context.Context, archiveID string, maxElements int) error {
+	t0 := time.Now()
+	err := m.rep.DeleteArchiveOldestMessages(ctx, archiveID, maxElements)
+	reportOpMetric(deleteOp, time.Since(t0).Seconds(), err == nil, m.inTx)
+	return err
+}
+
+func (m *measuredArchiveRep) DeleteArchive(ctx context.Context, archiveID string) error {
+	t0 := time.Now()
+	err := m.rep.DeleteArchive(ctx, archiveID)
+	reportOpMetric(deleteOp, time.Since(t0).Seconds(), err == nil, m.inTx)
+	return err
+}
diff --git a/pkg/storage/measured/archive_test.go b/pkg/storage/measured/archive_test.go
new file mode 100644
index 000000000..5cbb98912
--- /dev/null
+++ b/pkg/storage/measured/archive_test.go
@@ -0,0 +1,99 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 measuredrepository
+
+import (
+	"context"
+	"testing"
+
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	"github.com/stretchr/testify/require"
+)
+
+func TestMeasuredArchiveRep_InsertArchiveMessage(t *testing.T) {
+	// given
+	repMock := &repositoryMock{}
+	repMock.InsertArchiveMessageFunc = func(ctx context.Context, message *archivemodel.Message) error {
+		return nil
+	}
+	m := &measuredArchiveRep{rep: repMock}
+
+	// when
+	_ = m.InsertArchiveMessage(context.Background(), &archivemodel.Message{ArchiveId: "a1234"})
+
+	// then
+	require.Len(t, repMock.InsertArchiveMessageCalls(), 1)
+}
+
+func TestMeasuredArchiveRep_FetchArchiveMetadata(t *testing.T) {
+	// given
+	repMock := &repositoryMock{}
+	repMock.FetchArchiveMetadataFunc = func(ctx context.Context, archiveID string) (*archivemodel.Metadata, error) {
+		return nil, nil
+	}
+	m := &measuredArchiveRep{rep: repMock}
+
+	// when
+	_, _ = m.FetchArchiveMetadata(context.Background(), "a1234")
+
+	// then
+	require.Len(t, repMock.FetchArchiveMetadataCalls(), 1)
+}
+
+func TestMeasuredArchiveRep_FetchArchiveMessages(t *testing.T) {
+	// given
+	repMock := &repositoryMock{}
+	repMock.FetchArchiveMessagesFunc = func(ctx context.Context, f *archivemodel.Filters, archiveID string) ([]*archivemodel.Message, error) {
+		return nil, nil
+	}
+	m := &measuredArchiveRep{rep: repMock}
+
+	// when
+	_, _ = m.FetchArchiveMessages(context.Background(), &archivemodel.Filters{}, "a1234")
+
+	// then
+	require.Len(t, repMock.FetchArchiveMessagesCalls(), 1)
+}
+
+func TestMeasuredArchiveRep_DeleteArchiveOldestMessages(t *testing.T) {
+	// given
+	repMock := &repositoryMock{}
+	repMock.DeleteArchiveOldestMessagesFunc = func(ctx context.Context, archiveID string, maxElements int) error {
+		return nil
+	}
+	m := &measuredArchiveRep{rep: repMock}
+
+	// when
+	err := m.DeleteArchiveOldestMessages(context.Background(), "a1234", 10)
+
+	// then
+	require.Len(t, repMock.DeleteArchiveOldestMessagesCalls(), 1)
+	require.NoError(t, err)
+}
+
+func TestMeasuredArchiveRep_DeleteArchive(t *testing.T) {
+	// given
+	repMock := &repositoryMock{}
+	repMock.DeleteArchiveFunc = func(ctx context.Context, archiveId string) error {
+		return nil
+	}
+	m := &measuredArchiveRep{rep: repMock}
+
+	// when
+	_ = m.DeleteArchive(context.Background(), "a1234")
+
+	// then
+	require.Len(t, repMock.DeleteArchiveCalls(), 1)
+}
diff --git a/pkg/storage/measured/measured.go b/pkg/storage/measured/measured.go
index 01a7b3d1b..914613f24 100644
--- a/pkg/storage/measured/measured.go
+++ b/pkg/storage/measured/measured.go
@@ -40,6 +40,7 @@ type Measured struct {
 	measuredPrivateRep
 	measuredRosterRep
 	measuredVCardRep
+	measuredArchiveRep
 	measuredLocker
 	rep repository.Repository
 }
@@ -55,6 +56,7 @@ func New(rep repository.Repository) repository.Repository {
 		measuredPrivateRep:      measuredPrivateRep{rep: rep},
 		measuredRosterRep:       measuredRosterRep{rep: rep},
 		measuredVCardRep:        measuredVCardRep{rep: rep},
+		measuredArchiveRep:      measuredArchiveRep{rep: rep},
 		measuredLocker:          measuredLocker{rep: rep},
 		rep:                     rep,
 	}
diff --git a/pkg/storage/measured/tx.go b/pkg/storage/measured/tx.go
index 992b0379d..316287f56 100644
--- a/pkg/storage/measured/tx.go
+++ b/pkg/storage/measured/tx.go
@@ -25,6 +25,7 @@ type measuredTx struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 }
 
@@ -38,6 +39,7 @@ func newMeasuredTx(tx repository.Transaction) *measuredTx {
 		Private:      &measuredPrivateRep{rep: tx, inTx: true},
 		Roster:       &measuredRosterRep{rep: tx, inTx: true},
 		VCard:        &measuredVCardRep{rep: tx, inTx: true},
+		Archive:      &measuredArchiveRep{rep: tx, inTx: true},
 		Locker:       &measuredLocker{rep: tx, inTx: true},
 	}
 }
diff --git a/pkg/storage/pgsql/archive.go b/pkg/storage/pgsql/archive.go
new file mode 100644
index 000000000..a27cd148d
--- /dev/null
+++ b/pkg/storage/pgsql/archive.go
@@ -0,0 +1,214 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 pgsqlrepository
+
+import (
+	"context"
+	"database/sql"
+	"time"
+
+	sq "github.com/Masterminds/squirrel"
+	kitlog "github.com/go-kit/log"
+	"github.com/golang/protobuf/proto"
+	"github.com/jackal-xmpp/stravaganza"
+	"github.com/jackal-xmpp/stravaganza/jid"
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+const (
+	archiveTableName = "archives"
+
+	archiveStampFormat = "2006-01-02T15:04:05Z"
+)
+
+type pgSQLArchiveRep struct {
+	conn   conn
+	logger kitlog.Logger
+}
+
+func (r *pgSQLArchiveRep) InsertArchiveMessage(ctx context.Context, message *archivemodel.Message) error {
+	b, err := proto.Marshal(message.Message)
+	if err != nil {
+		return err
+	}
+	fromJID, _ := jid.NewWithString(message.FromJid, true)
+	toJID, _ := jid.NewWithString(message.ToJid, true)
+
+	q := sq.Insert(archiveTableName).
+		Prefix(noLoadBalancePrefix).
+		Columns("archive_id", "id", `"from"`, "from_bare", `"to"`, "to_bare", "message").
+		Values(
+			message.ArchiveId,
+			message.Id,
+			fromJID.String(),
+			fromJID.ToBareJID().String(),
+			toJID.String(),
+			toJID.ToBareJID().String(),
+			b,
+		)
+
+	_, err = q.RunWith(r.conn).ExecContext(ctx)
+	return err
+}
+
+func (r *pgSQLArchiveRep) FetchArchiveMetadata(ctx context.Context, archiveID string) (*archivemodel.Metadata, error) {
+	fromExpr := `FROM `
+	fromExpr += `(SELECT "id", created_at FROM archives WHERE serial = (SELECT MIN(serial) FROM archives WHERE archive_id = $1)) AS min,`
+	fromExpr += `(SELECT "id", created_at FROM archives WHERE serial = (SELECT MAX(serial) FROM archives WHERE archive_id = $1)) AS max`
+
+	q := sq.Select("min.id, min.created_at, max.id, max.created_at").Suffix(fromExpr, archiveID)
+
+	var start, end time.Time
+	var metadata archivemodel.Metadata
+
+	err := q.RunWith(r.conn).
+		QueryRowContext(ctx).
+		Scan(
+			&metadata.StartId,
+			&start,
+			&metadata.EndId,
+			&end,
+		)
+
+	switch err {
+	case nil:
+		metadata.StartTimestamp = start.UTC().Format(archiveStampFormat)
+		metadata.EndTimestamp = end.UTC().Format(archiveStampFormat)
+		return &metadata, nil
+
+	case sql.ErrNoRows:
+		return nil, nil
+
+	default:
+		return nil, err
+	}
+}
+
+func (r *pgSQLArchiveRep) FetchArchiveMessages(ctx context.Context, f *archivemodel.Filters, archiveID string) ([]*archivemodel.Message, error) {
+	q := sq.Select("id", `"from"`, `"to"`, "message", "created_at").
+		From(archiveTableName).
+		Where(filtersToPred(f, archiveID)).
+		OrderBy("created_at").
+		PlaceholderFormat(sq.Dollar)
+
+	rows, err := q.RunWith(r.conn).QueryContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+	defer closeRows(rows, r.logger)
+
+	retVal, err := scanArchiveMessages(rows, archiveID)
+	if err != nil {
+		return nil, err
+	}
+	return retVal, err
+}
+
+func (r *pgSQLArchiveRep) DeleteArchiveOldestMessages(ctx context.Context, archiveID string, maxElements int) error {
+	q := sq.Delete(archiveTableName).
+		Prefix(noLoadBalancePrefix).
+		Where(sq.And{
+			sq.Eq{"archive_id": archiveID},
+			sq.Expr(`"id" NOT IN (SELECT "id" FROM archives WHERE archive_id = $2 ORDER BY created_at DESC LIMIT $3 OFFSET 0)`, archiveID, maxElements),
+		})
+	_, err := q.RunWith(r.conn).ExecContext(ctx)
+	return err
+}
+
+func (r *pgSQLArchiveRep) DeleteArchive(ctx context.Context, archiveID string) error {
+	q := sq.Delete(archiveTableName).
+		Prefix(noLoadBalancePrefix).
+		Where(sq.Eq{"archive_id": archiveID})
+	_, err := q.RunWith(r.conn).ExecContext(ctx)
+	return err
+}
+
+func filtersToPred(f *archivemodel.Filters, archiveID string) (interface{}, error) {
+	pred := sq.And{
+		sq.Eq{"archive_id": archiveID},
+	}
+	// filtering by JID
+	if len(f.With) > 0 {
+		jd, err := jid.NewWithString(f.With, false)
+		if err != nil {
+			return nil, err
+		}
+		switch {
+		case jd.IsFull():
+			pred = append(pred, sq.Expr(`("to" = ? OR "from" = ?)`, jd.String(), jd.String()))
+
+		default:
+			pred = append(pred, sq.Expr(`(to_bare = ? OR from_bare = ?)`, jd.String(), jd.String()))
+		}
+	}
+
+	// filtering by id
+	if len(f.Ids) > 0 {
+		pred = append(pred, sq.Eq{"id": f.Ids})
+	} else {
+		if len(f.BeforeId) > 0 {
+			pred = append(pred, sq.Expr(`(serial < (SELECT serial FROM archives WHERE "id" = ? AND archive_id = ?))`, f.BeforeId, archiveID))
+		}
+		if len(f.AfterId) > 0 {
+			pred = append(pred, sq.Expr(`(serial > (SELECT serial FROM archives WHERE "id" = ? AND archive_id = ?))`, f.AfterId, archiveID))
+		}
+	}
+
+	// filtering by timestamp
+	if f.Start != nil {
+		pred = append(pred, sq.Expr("EXTRACT(epoch FROM created_at) > ?", f.Start.GetSeconds()))
+	}
+	if f.End != nil {
+		pred = append(pred, sq.Expr("EXTRACT(epoch FROM created_at) < ?", f.End.GetSeconds()))
+	}
+	return pred, nil
+}
+
+func scanArchiveMessages(scanner rowsScanner, archiveID string) ([]*archivemodel.Message, error) {
+	var ret []*archivemodel.Message
+	for scanner.Next() {
+		msg, err := scanArchiveMessage(scanner, archiveID)
+		if err != nil {
+			return nil, err
+		}
+		ret = append(ret, msg)
+	}
+	return ret, nil
+}
+
+func scanArchiveMessage(scanner rowsScanner, archiveID string) (*archivemodel.Message, error) {
+	var ret archivemodel.Message
+
+	var b []byte
+	var tm time.Time
+
+	if err := scanner.Scan(&ret.Id, &ret.FromJid, &ret.ToJid, &b, &tm); err != nil {
+		return nil, err
+	}
+	sb, err := stravaganza.NewBuilderFromBinary(b)
+	if err != nil {
+		return nil, err
+	}
+	msg, err := sb.BuildMessage()
+	if err != nil {
+		return nil, err
+	}
+	ret.ArchiveId = archiveID
+	ret.Message = msg.Proto()
+	ret.Stamp = timestamppb.New(tm)
+
+	return &ret, nil
+}
diff --git a/pkg/storage/pgsql/archive_test.go b/pkg/storage/pgsql/archive_test.go
new file mode 100644
index 000000000..b053941c2
--- /dev/null
+++ b/pkg/storage/pgsql/archive_test.go
@@ -0,0 +1,217 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 pgsqlrepository
+
+import (
+	"context"
+	"database/sql/driver"
+	"testing"
+	"time"
+
+	"github.com/DATA-DOG/go-sqlmock"
+	"github.com/golang/protobuf/proto"
+	"github.com/jackal-xmpp/stravaganza"
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func TestPgSQLArchive_InsertArchiveMessage(t *testing.T) {
+	// given
+	b := stravaganza.NewMessageBuilder()
+	b.WithAttribute("from", "noelia@jackal.im/yard")
+	b.WithAttribute("to", "ortuman@jackal.im/balcony")
+	b.WithChild(
+		stravaganza.NewBuilder("body").
+			WithText("I'll give thee a wind.").
+			Build(),
+	)
+	msg, _ := b.BuildMessage()
+
+	aMsg := &archivemodel.Message{
+		ArchiveId: "ortuman",
+		Id:        "id1234",
+		FromJid:   "ortuman@jackal.im/local",
+		ToJid:     "ortuman@jabber.org/remote",
+		Message:   msg.Proto(),
+	}
+	msgBytes, _ := proto.Marshal(aMsg.Message)
+
+	s, mock := newArchiveMock()
+	mock.ExpectExec(`INSERT INTO archives \(archive_id,id,"from",from_bare,"to",to_bare,message\) VALUES \(\$1,\$2,\$3,\$4,\$5,\$6,\$7\)`).
+		WithArgs("ortuman", "id1234", "ortuman@jackal.im/local", "ortuman@jackal.im", "ortuman@jabber.org/remote", "ortuman@jabber.org", msgBytes).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+
+	// when
+	err := s.InsertArchiveMessage(context.Background(), aMsg)
+
+	// then
+	require.Nil(t, err)
+	require.Nil(t, mock.ExpectationsWereMet())
+}
+
+func TestPgSQLArchive_FetchArchiveMetadata(t *testing.T) {
+	minT := time.Date(2022, 01, 01, 00, 00, 00, 00, time.UTC)
+	maxT := time.Date(2022, 12, 12, 00, 00, 00, 00, time.UTC)
+
+	// given
+	s, mock := newArchiveMock()
+	mock.ExpectQuery(`SELECT min.id, min.created_at, max.id, max.created_at FROM \(SELECT "id", created_at FROM archives WHERE serial = \(SELECT MIN\(serial\) FROM archives WHERE archive_id = \$1\)\) AS min,\(SELECT "id", created_at FROM archives WHERE serial = \(SELECT MAX\(serial\) FROM archives WHERE archive_id = \$1\)\) AS max`).
+		WithArgs("ortuman").
+		WillReturnRows(
+			sqlmock.NewRows([]string{"min.id", "min.created_at", "max.id", "max.created_at"}).AddRow("YWxwaGEg", minT, "b21lZ2Eg", maxT),
+		)
+
+	// when
+	metadata, err := s.FetchArchiveMetadata(context.Background(), "ortuman")
+
+	// then
+	require.Nil(t, err)
+	require.NotNil(t, metadata)
+
+	require.Equal(t, "YWxwaGEg", metadata.StartId)
+	require.Equal(t, "2022-01-01T00:00:00Z", metadata.StartTimestamp)
+	require.Equal(t, "b21lZ2Eg", metadata.EndId)
+	require.Equal(t, "2022-12-12T00:00:00Z", metadata.EndTimestamp)
+
+	require.Nil(t, mock.ExpectationsWereMet())
+}
+
+func TestPgSQLArchive_FetchArchiveMessages(t *testing.T) {
+	starTm := time.Date(2022, time.July, 6, 14, 7, 43, 167051000, time.UTC)
+	endTm := time.Date(2023, time.July, 7, 15, 7, 43, 167051000, time.UTC)
+
+	tcs := map[string]struct {
+		filters     *archivemodel.Filters
+		withArgs    []driver.Value
+		expectQuery string
+	}{
+		"by bare jid": {
+			filters:     &archivemodel.Filters{With: "noelia@jackal.im"},
+			withArgs:    []driver.Value{"ortuman", "noelia@jackal.im", "noelia@jackal.im"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND \(to_bare = \$2 OR from_bare = \$3\)\) ORDER BY created_at`,
+		},
+		"by full jid": {
+			filters:     &archivemodel.Filters{With: "noelia@jackal.im/yard"},
+			withArgs:    []driver.Value{"ortuman", "noelia@jackal.im/yard", "noelia@jackal.im/yard"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND \("to" = \$2 OR "from" = \$3\)\) ORDER BY created_at`,
+		},
+		"by ids": {
+			filters:     &archivemodel.Filters{Ids: []string{"id1234", "id5678"}},
+			withArgs:    []driver.Value{"ortuman", "id1234", "id5678"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND id IN \(\$2,\$3\)\) ORDER BY created_at`,
+		},
+		"by before id": {
+			filters:     &archivemodel.Filters{BeforeId: "id1234"},
+			withArgs:    []driver.Value{"ortuman", "id1234", "ortuman"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND \(serial < \(SELECT serial FROM archives WHERE "id" = \$2 AND archive_id = \$3\)\)\) ORDER BY created_at`,
+		},
+		"by after id": {
+			filters:     &archivemodel.Filters{AfterId: "id1234"},
+			withArgs:    []driver.Value{"ortuman", "id1234", "ortuman"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND \(serial > \(SELECT serial FROM archives WHERE "id" = \$2 AND archive_id = \$3\)\)\) ORDER BY created_at`,
+		},
+		"by before and after id": {
+			filters:     &archivemodel.Filters{BeforeId: "id1234", AfterId: "id5678"},
+			withArgs:    []driver.Value{"ortuman", "id1234", "ortuman", "id5678", "ortuman"},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND \(serial < \(SELECT serial FROM archives WHERE "id" = \$2 AND archive_id = \$3\)\) AND \(serial > \(SELECT serial FROM archives WHERE "id" = \$4 AND archive_id = \$5\)\)\) ORDER BY created_at`,
+		},
+		"by start timestamp": {
+			filters:     &archivemodel.Filters{Start: timestamppb.New(starTm)},
+			withArgs:    []driver.Value{"ortuman", starTm.Unix()},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND EXTRACT\(epoch FROM created_at\) > \$2\) ORDER BY created_at`,
+		},
+		"by end timestamp": {
+			filters:     &archivemodel.Filters{End: timestamppb.New(endTm)},
+			withArgs:    []driver.Value{"ortuman", endTm.Unix()},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND EXTRACT\(epoch FROM created_at\) < \$2\) ORDER BY created_at`,
+		},
+		"by start and end timestamp": {
+			filters:     &archivemodel.Filters{Start: timestamppb.New(starTm), End: timestamppb.New(endTm)},
+			withArgs:    []driver.Value{"ortuman", starTm.Unix(), endTm.Unix()},
+			expectQuery: `SELECT id, "from", "to", message, created_at FROM archives WHERE \(archive_id = \$1 AND EXTRACT\(epoch FROM created_at\) > \$2 AND EXTRACT\(epoch FROM created_at\) < \$3\) ORDER BY created_at`,
+		},
+	}
+	for tn, tc := range tcs {
+		t.Run(tn, func(t *testing.T) {
+			b := stravaganza.NewMessageBuilder()
+			b.WithAttribute("from", "noelia@jackal.im/yard")
+			b.WithAttribute("to", "ortuman@jackal.im/balcony")
+			b.WithChild(
+				stravaganza.NewBuilder("body").
+					WithText("I'll give thee a wind.").
+					Build(),
+			)
+			msg, _ := b.BuildMessage()
+
+			msgBytes, _ := msg.MarshalBinary()
+			tmNow := time.Date(2022, time.July, 6, 14, 7, 43, 167051000, time.UTC)
+
+			rows := sqlmock.NewRows([]string{"id", "from", "to", "message", "created_at"}).
+				AddRow("id1234", "ortuman@jackal.im", "noelia@jackal.im", msgBytes, tmNow)
+
+			s, mock := newArchiveMock()
+			mock.ExpectQuery(tc.expectQuery).
+				WithArgs(tc.withArgs...).
+				WillReturnRows(rows)
+
+			// when
+			messages, err := s.FetchArchiveMessages(context.Background(), tc.filters, "ortuman")
+
+			require.NoError(t, err)
+			require.Nil(t, mock.ExpectationsWereMet())
+
+			// then
+			require.Len(t, messages, 1)
+			require.Equal(t, "id1234", messages[0].Id)
+			require.Equal(t, tmNow, messages[0].Stamp.AsTime())
+		})
+	}
+}
+
+func TestPgSQLArchive_DeleteArchiveOldestMessages(t *testing.T) {
+	// given
+	s, mock := newArchiveMock()
+	mock.ExpectExec(`DELETE FROM archives WHERE \(archive_id = \$1 AND "id" NOT IN \(SELECT "id" FROM archives WHERE archive_id = \$2 ORDER BY created_at DESC LIMIT \$3 OFFSET 0\)\)`).
+		WithArgs("ortuman", "ortuman", 1234).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+
+	// when
+	err := s.DeleteArchiveOldestMessages(context.Background(), "ortuman", 1234)
+
+	// then
+	require.Nil(t, err)
+	require.Nil(t, mock.ExpectationsWereMet())
+}
+
+func TestPgSQLArchive_DeleteArchive(t *testing.T) {
+	// given
+	s, mock := newArchiveMock()
+	mock.ExpectExec(`DELETE FROM archives WHERE archive_id = \$1`).
+		WithArgs("ortuman").
+		WillReturnResult(sqlmock.NewResult(1, 1))
+
+	// when
+	err := s.DeleteArchive(context.Background(), "ortuman")
+
+	// then
+	require.Nil(t, err)
+	require.Nil(t, mock.ExpectationsWereMet())
+}
+
+func newArchiveMock() (*pgSQLArchiveRep, sqlmock.Sqlmock) {
+	s, sqlMock := newPgSQLMock()
+	return &pgSQLArchiveRep{conn: s}, sqlMock
+}
diff --git a/pkg/storage/pgsql/repository.go b/pkg/storage/pgsql/repository.go
index 3a0acb9f9..32a665e83 100644
--- a/pkg/storage/pgsql/repository.go
+++ b/pkg/storage/pgsql/repository.go
@@ -57,6 +57,7 @@ type Repository struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 
 	host string
@@ -120,6 +121,7 @@ func (r *Repository) Start(ctx context.Context) error {
 	r.Private = &pgSQLPrivateRep{conn: db, logger: r.logger}
 	r.Roster = &pgSQLRosterRep{conn: db, logger: r.logger}
 	r.VCard = &pgSQLVCardRep{conn: db, logger: r.logger}
+	r.Archive = &pgSQLArchiveRep{conn: db, logger: r.logger}
 	r.Locker = &pgSQLLocker{conn: db}
 	return nil
 }
diff --git a/pkg/storage/pgsql/tx.go b/pkg/storage/pgsql/tx.go
index 99d6be102..18ccde9a5 100644
--- a/pkg/storage/pgsql/tx.go
+++ b/pkg/storage/pgsql/tx.go
@@ -29,6 +29,7 @@ type repTx struct {
 	repository.Private
 	repository.Roster
 	repository.VCard
+	repository.Archive
 	repository.Locker
 }
 
@@ -42,6 +43,7 @@ func newRepTx(tx *sql.Tx) *repTx {
 		Private:      &pgSQLPrivateRep{conn: tx},
 		Roster:       &pgSQLRosterRep{conn: tx},
 		VCard:        &pgSQLVCardRep{conn: tx},
+		Archive:      &pgSQLArchiveRep{conn: tx},
 		Locker:       &pgSQLLocker{conn: tx},
 	}
 }
diff --git a/pkg/storage/repository/archive.go b/pkg/storage/repository/archive.go
new file mode 100644
index 000000000..9aec3f73b
--- /dev/null
+++ b/pkg/storage/repository/archive.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The jackal Authors
+//
+// 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 repository
+
+import (
+	"context"
+
+	archivemodel "github.com/ortuman/jackal/pkg/model/archive"
+)
+
+// Archive defines storage operations for message archive
+type Archive interface {
+	// InsertArchiveMessage inserts a new message element into an archive queue.
+	InsertArchiveMessage(ctx context.Context, message *archivemodel.Message) error
+
+	// FetchArchiveMetadata returns the metadata value associated to an archive.
+	FetchArchiveMetadata(ctx context.Context, archiveID string) (*archivemodel.Metadata, error)
+
+	// FetchArchiveMessages fetches archive asscociated messages applying the passed f filters.
+	FetchArchiveMessages(ctx context.Context, f *archivemodel.Filters, archiveID string) ([]*archivemodel.Message, error)
+
+	// DeleteArchiveOldestMessages trims archive oldest messages up to a maxElements total count.
+	DeleteArchiveOldestMessages(ctx context.Context, archiveID string, maxElements int) error
+
+	// DeleteArchive clears an archive queue.
+	DeleteArchive(ctx context.Context, archiveID string) error
+}
diff --git a/pkg/storage/repository/repository.go b/pkg/storage/repository/repository.go
index 51b9c913b..e5a6fe100 100644
--- a/pkg/storage/repository/repository.go
+++ b/pkg/storage/repository/repository.go
@@ -38,6 +38,7 @@ type Transaction interface {
 }
 
 type baseRepository interface {
+	Archive
 	User
 	Last
 	Capabilities
diff --git a/pkg/util/xmpp/xmpp.go b/pkg/util/xmpp/xmpp.go
index 8aca645d3..bdaac11df 100644
--- a/pkg/util/xmpp/xmpp.go
+++ b/pkg/util/xmpp/xmpp.go
@@ -22,6 +22,8 @@ import (
 	"github.com/jackal-xmpp/stravaganza/jid"
 )
 
+const delayTimeFormat = "2006-01-02T15:04:05Z"
+
 // MakeResultIQ creates a new result stanza derived from iq.
 func MakeResultIQ(iq *stravaganza.IQ, queryChild stravaganza.Element) *stravaganza.IQ {
 	b := iq.ResultBuilder()
@@ -57,10 +59,53 @@ func MakeDelayMessage(stanza stravaganza.Stanza, stamp time.Time, from, text str
 		stravaganza.NewBuilder("delay").
 			WithAttribute(stravaganza.Namespace, "urn:xmpp:delay").
 			WithAttribute(stravaganza.From, from).
-			WithAttribute("stamp", stamp.UTC().Format("2006-01-02T15:04:05Z")).
+			WithAttribute("stamp", stamp.UTC().Format(delayTimeFormat)).
 			WithText(text).
 			Build(),
 	)
 	dMsg, _ := sb.BuildMessage()
 	return dMsg
 }
+
+// MakeStanzaIDMessage creates and returns a new message containing a stanza-id element.
+func MakeStanzaIDMessage(originalMsg *stravaganza.Message, stanzaID, by string) *stravaganza.Message {
+	msg, _ := stravaganza.NewBuilderFromElement(originalMsg).
+		WithChild(
+			stravaganza.NewBuilder("stanza-id").
+				WithAttribute(stravaganza.Namespace, "urn:xmpp:sid:0").
+				WithAttribute("by", by).
+				WithAttribute("id", stanzaID).
+				Build(),
+		).
+		BuildMessage()
+	return msg
+}
+
+// MessageStanzaID returns the stanza-id value contained in msg parameter.
+func MessageStanzaID(msg *stravaganza.Message) string {
+	sidElem := msg.ChildNamespace("stanza-id", "urn:xmpp:sid:0")
+	if sidElem == nil {
+		return ""
+	}
+	return sidElem.Attribute("id")
+}
+
+// MakeForwardedStanza creates a new forwarded element derived from the passed stanza.
+func MakeForwardedStanza(stanza stravaganza.Stanza, stamp *time.Time) stravaganza.Element {
+	b := stravaganza.NewBuilder("forwarded").
+		WithAttribute(stravaganza.Namespace, "urn:xmpp:forward:0").
+		WithChild(
+			stravaganza.NewBuilderFromElement(stanza).
+				WithAttribute(stravaganza.Namespace, "jabber:client").
+				Build(),
+		)
+	if stamp != nil {
+		b.WithChild(
+			stravaganza.NewBuilder("delay").
+				WithAttribute(stravaganza.Namespace, "urn:xmpp:delay").
+				WithAttribute("stamp", stamp.UTC().Format(delayTimeFormat)).
+				Build(),
+		)
+	}
+	return b.Build()
+}
diff --git a/pkg/util/xmpp/xmpp_test.go b/pkg/util/xmpp/xmpp_test.go
index c11fd36b1..9d7aef9cc 100644
--- a/pkg/util/xmpp/xmpp_test.go
+++ b/pkg/util/xmpp/xmpp_test.go
@@ -126,3 +126,55 @@ func TestMakeDelayStanza(t *testing.T) {
 	require.Equal(t, "2021-02-15T15:00:00Z", dChild.Attribute("stamp"))
 	require.Equal(t, "Delayed IQ", dChild.Text())
 }
+
+func TestMakeStanzaIDElement(t *testing.T) {
+	// given
+	b := stravaganza.NewMessageBuilder()
+	b.WithAttribute("from", "noelia@jackal.im/yard")
+	b.WithAttribute("to", "ortuman@jackal.im/balcony")
+	b.WithChild(
+		stravaganza.NewBuilder("body").
+			WithText("I'll give thee a wind.").
+			Build(),
+	)
+	msg, _ := b.BuildMessage()
+
+	// when
+	msg = MakeStanzaIDMessage(msg, "1234", "ortuman@jackal.im")
+
+	// then
+	elem := msg.ChildNamespace("stanza-id", "urn:xmpp:sid:0")
+	require.NotNil(t, elem)
+
+	require.Equal(t, "1234", MessageStanzaID(msg))
+	require.Equal(t, "ortuman@jackal.im", elem.Attribute("by"))
+}
+
+func TestMakeForwardedElement(t *testing.T) {
+	// given
+	b := stravaganza.NewMessageBuilder()
+	b.WithAttribute("from", "noelia@jackal.im/yard")
+	b.WithAttribute("to", "ortuman@jackal.im/balcony")
+	b.WithChild(
+		stravaganza.NewBuilder("body").
+			WithText("I'll give thee a wind.").
+			Build(),
+	)
+	msg, _ := b.BuildMessage()
+
+	stamp, _ := time.Parse(time.RFC3339, "2021-02-15T15:00:00Z")
+	forwarded := MakeForwardedStanza(msg, &stamp)
+
+	// when
+	require.Equal(t, "urn:xmpp:forward:0", forwarded.Attribute(stravaganza.Namespace))
+
+	dChild := forwarded.Child("delay")
+	require.NotNil(t, dChild)
+	require.Equal(t, "2021-02-15T15:00:00Z", dChild.Attribute("stamp"))
+
+	msgEl := forwarded.Child("message")
+	require.NotNil(t, msgEl)
+	bodyEl := msgEl.Child("body")
+	require.NotNil(t, bodyEl)
+	require.Equal(t, "I'll give thee a wind.", bodyEl.Text())
+}
diff --git a/proto/model/v1/archive.proto b/proto/model/v1/archive.proto
new file mode 100644
index 000000000..5e3a1bde8
--- /dev/null
+++ b/proto/model/v1/archive.proto
@@ -0,0 +1,85 @@
+// Copyright 2022 The jackal Authors
+//
+// 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.
+
+syntax="proto3";
+
+import "google/protobuf/timestamp.proto";
+
+import "github.com/jackal-xmpp/stravaganza/stravaganza.proto";
+
+package model.archive.v1;
+
+option go_package = "pkg/model/archive/;archivemodel";
+
+// Message represents an archive message entity.
+message Message {
+  // archived_id is the message archive identifier.
+  string archive_id = 1;
+
+  // id is the message archive unique identifier.
+  string id = 2;
+
+  // from_jid is the message from jid value.
+  string from_jid = 3;
+
+  // to_jid is the message from jid value.
+  string to_jid = 4;
+
+  // message is the archived message.
+  stravaganza.PBElement message = 5;
+
+  // stamp is the timestamp in which the message was archived.
+  google.protobuf.Timestamp stamp = 9;
+}
+
+// Messages represents a set of archive messages.
+message Messages {
+  repeated Message archive_messages = 1;
+}
+
+// Metadata represents an archive metadata information.
+message Metadata {
+  // start_timestamp is the identifier of the first archive message.
+  string start_id = 1;
+
+  // start_timestamp is the timestamp value of the first archive message.
+  string start_timestamp = 2;
+
+  // end_id is the identifier of the last archive message.
+  string end_id = 3;
+
+  // end_timestamp is the timestamp value of the last archive message.
+  string end_timestamp = 4;
+}
+
+// Filters define a set of filters to be applied when fetching archive messages.
+message Filters {
+  // start is used to filter out messages before a certain date/time.
+  google.protobuf.Timestamp start = 1;
+
+  // end is used to filter out messages after a certain date/time.
+  google.protobuf.Timestamp end = 2;
+
+  // with contains a JID against which to match messages.
+  string with = 3;
+
+  // before_id is the id of the newest message user wants to fetch.
+  string before_id = 4;
+
+  // after_id is the id of the oldest message user wants to fetch.
+  string after_id = 5;
+
+  // ids contains one or more ids the user wants to fetch.
+  repeated string ids = 6;
+}
diff --git a/scripts/genproto.sh b/scripts/genproto.sh
index bf7be5bfd..dca80d4d0 100755
--- a/scripts/genproto.sh
+++ b/scripts/genproto.sh
@@ -15,6 +15,7 @@ FILES=(
   "admin/v1/users.proto"
   "c2s/v1/resourceinfo.proto"
   "cluster/v1/cluster.proto"
+  "model/v1/archive.proto"
   "model/v1/user.proto"
   "model/v1/last.proto"
   "model/v1/blocklist.proto"
diff --git a/sql/postgres.up.psql b/sql/postgres.up.psql
index 3be5cf8da..39d0ad1c1 100644
--- a/sql/postgres.up.psql
+++ b/sql/postgres.up.psql
@@ -170,3 +170,25 @@ CREATE TABLE IF NOT EXISTS vcards (
 );
 
 SELECT enable_updated_at('vcards');
+
+-- archives
+
+CREATE TABLE IF NOT EXISTS archives (
+    serial     SERIAL PRIMARY KEY,
+    archive_id VARCHAR(1023),
+    id         VARCHAR(255) NOT NULL,
+    "from"     TEXT NOT NULL,
+    from_bare  TEXT NOT NULL,
+    "to"       TEXT NOT NULL,
+    to_bare    TEXT NOT NULL,
+    message    BYTEA NOT NULL,
+    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS i_archives_archive_id ON archives(archive_id);
+CREATE INDEX IF NOT EXISTS i_archives_id ON archives(id);
+CREATE INDEX IF NOT EXISTS i_archives_to ON archives("to");
+CREATE INDEX IF NOT EXISTS i_archives_to_bare ON archives(to_bare);
+CREATE INDEX IF NOT EXISTS i_archives_from ON archives("from");
+CREATE INDEX IF NOT EXISTS i_archives_from_bare ON archives(from_bare);
+CREATE INDEX IF NOT EXISTS i_archives_created_at ON archives(created_at);