diff --git a/gradle.properties b/gradle.properties
index 2d8d1e4dd150cb790e1efef522121e30222820d4..98026c56f7ececb04c7dc71c78524d831cf92b19 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1,2 @@
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx1g
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt
index 336cac11ff218437b8dbc3e94287f33117a97b59..56e1dd9fa81c6d384e4b8a7a9a5440dfeca440c7 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt
@@ -2,9 +2,8 @@ package org.briarproject.mailbox.core.db
 
 import org.briarproject.mailbox.core.api.Contact
 import org.briarproject.mailbox.core.settings.Settings
-import java.sql.Connection
 
-interface Database<T> {
+interface Database : TransactionManager {
 
     /**
      * Opens the database and returns true if the database already existed.
@@ -18,38 +17,19 @@ interface Database<T> {
     @Throws(DbException::class)
     fun close()
 
-    /**
-     * Starts a new transaction and returns an object representing it.
-     */
-    @Throws(DbException::class)
-    fun startTransaction(): T
-
-    /**
-     * Aborts the given transaction - no changes made during the transaction
-     * will be applied to the database.
-     */
-    fun abortTransaction(txn: T)
-
-    /**
-     * Commits the given transaction - all changes made during the transaction
-     * will be applied to the database.
-     */
-    @Throws(DbException::class)
-    fun commitTransaction(txn: T)
-
     @Throws(DbException::class)
-    fun getSettings(txn: Connection, namespace: String?): Settings
+    fun getSettings(txn: Transaction, namespace: String): Settings
 
     @Throws(DbException::class)
-    fun mergeSettings(txn: Connection, s: Settings, namespace: String?)
+    fun mergeSettings(txn: Transaction, s: Settings, namespace: String)
 
     @Throws(DbException::class)
-    fun addContact(txn: T, contact: Contact)
+    fun addContact(txn: Transaction, contact: Contact)
 
     @Throws(DbException::class)
-    fun getContact(txn: T, id: Int): Contact?
+    fun getContact(txn: Transaction, id: Int): Contact?
 
     @Throws(DbException::class)
-    fun removeContact(txn: T, id: Int)
+    fun removeContact(txn: Transaction, id: Int)
 
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java
deleted file mode 100644
index 88e0e248e8413ebe18e2e1728d623f167e35ce26..0000000000000000000000000000000000000000
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponent.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.briarproject.mailbox.core.db;
-
-import javax.annotation.Nullable;
-
-/**
- * Encapsulates the database implementation and exposes high-level operations
- * to other components.
- */
-public interface DatabaseComponent {
-
-    /**
-     * Opens the database and returns true if the database already existed.
-     */
-    boolean open(@Nullable MigrationListener listener);
-
-    /**
-     * Waits for any open transactions to finish and closes the database.
-     */
-    void close();
-
-}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt
deleted file mode 100644
index e17f7014cce00c48304d7d938c39e52e3c78b8da..0000000000000000000000000000000000000000
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseComponentImpl.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.briarproject.mailbox.core.db
-
-import javax.inject.Inject
-
-class DatabaseComponentImpl<T> @Inject constructor(database: Database<T>) : DatabaseComponent {
-
-    private val db: Database<T>? = null
-
-    override fun open(listener: MigrationListener?): Boolean {
-        // TODO: implement this
-        return true
-    }
-
-    override fun close() {
-        // TODO: implement this
-    }
-
-}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt
index 84a4e28308ca4866d54a27145043a1a7686b3820..e32d0fc766b3d5afeac82f95b08fed3652ef22db 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/DatabaseModule.kt
@@ -5,7 +5,6 @@ import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.components.SingletonComponent
 import org.briarproject.mailbox.core.system.Clock
-import java.sql.Connection
 import javax.inject.Singleton
 
 @Module
@@ -14,14 +13,8 @@ internal class DatabaseModule {
 
     @Provides
     @Singleton
-    fun provideDatabase(config: DatabaseConfig, clock: Clock): Database<Connection> {
+    fun provideDatabase(config: DatabaseConfig, clock: Clock): Database {
         return H2Database(config, clock)
     }
 
-    @Provides
-    @Singleton
-    fun provideDatabaseComponent(db: Database<Connection>): DatabaseComponent {
-        return DatabaseComponentImpl(db)
-    }
-
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt
index a0433b4879e8653fca233adc7182d118393f1f44..b46c2d3409b5dbade799c805863fe47b0ece68ee 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt
@@ -22,10 +22,11 @@ import java.util.Arrays
 import java.util.LinkedList
 import java.util.concurrent.locks.Lock
 import java.util.concurrent.locks.ReentrantLock
+import java.util.concurrent.locks.ReentrantReadWriteLock
 import javax.annotation.concurrent.GuardedBy
 
 abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val clock: Clock) :
-    Database<Connection> {
+    Database {
 
     internal companion object {
 
@@ -64,6 +65,8 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     @Volatile
     private var wasDirtyOnInitialisation = false
 
+    private val lock = ReentrantReadWriteLock(true)
+
     fun open(driverClass: String, reopen: Boolean, listener: MigrationListener?) {
         // Load the JDBC driver
         try {
@@ -72,27 +75,23 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
             throw DbException(e)
         }
         // Open the database and create the tables and indexes if necessary
-        val compact: Boolean
-        var txn = startTransaction()
-        try {
+        var compact = false
+        transaction(false) { txn ->
+            val connection = txn.unbox()
             compact = if (reopen) {
-                val s: Settings = getSettings(txn, DB_SETTINGS_NAMESPACE)
+                val s: Settings = getSettings(connection, DB_SETTINGS_NAMESPACE)
                 wasDirtyOnInitialisation = isDirty(s)
-                migrateSchema(txn, s, listener) || isCompactionDue(s)
+                migrateSchema(connection, s, listener) || isCompactionDue(s)
             } else {
                 wasDirtyOnInitialisation = false
-                createTables(txn)
-                initialiseSettings(txn)
+                createTables(connection)
+                initialiseSettings(connection)
                 false
             }
             if (LOG.isInfoEnabled) {
                 LOG.info("db dirty? $wasDirtyOnInitialisation")
             }
-            createIndexes(txn)
-            commitTransaction(txn)
-        } catch (e: DbException) {
-            abortTransaction(txn)
-            throw e
+            createIndexes(connection)
         }
         // Compact the database if necessary
         if (compact) {
@@ -102,13 +101,8 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
             logDuration(LOG, { "Compacting database" }, start)
             // Allow the next transaction to reopen the DB
             synchronized(connectionsLock) { closed = false }
-            txn = startTransaction()
-            try {
-                storeLastCompacted(txn)
-                commitTransaction(txn)
-            } catch (e: DbException) {
-                abortTransaction(txn)
-                throw e
+            transaction(false) { txn ->
+                storeLastCompacted(txn.unbox())
             }
         }
     }
@@ -126,7 +120,11 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
      * current code and cannot be migrated
      */
     @Throws(DbException::class)
-    private fun migrateSchema(txn: Connection, s: Settings, listener: MigrationListener?): Boolean {
+    private fun migrateSchema(
+        connection: Connection,
+        s: Settings,
+        listener: MigrationListener?,
+    ): Boolean {
         var dataSchemaVersion = s.getInt(SCHEMA_VERSION_KEY, -1)
         if (dataSchemaVersion == -1) throw DbException()
         if (dataSchemaVersion == CODE_SCHEMA_VERSION) return false
@@ -139,9 +137,9 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
                 if (LOG.isInfoEnabled) LOG.info("Migrating from schema $start to $end")
                 listener?.onDatabaseMigration()
                 // Apply the migration
-                m.migrate(txn)
+                m.migrate(connection)
                 // Store the new schema version
-                storeSchemaVersion(txn, end)
+                storeSchemaVersion(connection, end)
                 dataSchemaVersion = end
             }
         }
@@ -162,20 +160,51 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     @Throws(DbException::class)
     protected abstract fun compactAndClose()
 
-    override fun startTransaction(): Connection {
-        var txn: Connection?
+    /**
+     * Starts a new transaction and returns an object representing it.
+     *
+     * This method acquires locks, so it must not be called while holding a
+     * lock.
+     *
+     * @param readOnly true if the transaction will only be used for reading.
+     */
+    private fun startTransaction(readOnly: Boolean): Transaction {
+        // Don't allow reentrant locking
+        check(lock.readHoldCount <= 0)
+        check(lock.writeHoldCount <= 0)
+        val start = now()
+        if (readOnly) {
+            lock.readLock().lock()
+            logDuration(LOG, { "Waiting for read lock" }, start)
+        } else {
+            lock.writeLock().lock()
+            logDuration(LOG, { "Waiting for write lock" }, start)
+        }
+        return try {
+            Transaction(startTransaction(), readOnly)
+        } catch (e: DbException) {
+            if (readOnly) lock.readLock().unlock() else lock.writeLock().unlock()
+            throw e
+        } catch (e: RuntimeException) {
+            if (readOnly) lock.readLock().unlock() else lock.writeLock().unlock()
+            throw e
+        }
+    }
+
+    private fun startTransaction(): Connection {
+        var connection: Connection?
         connectionsLock.lock()
-        txn = try {
+        connection = try {
             if (closed) throw DbClosedException()
             connections.poll()
         } finally {
             connectionsLock.unlock()
         }
         try {
-            if (txn == null) {
+            if (connection == null) {
                 // Open a new connection
-                txn = createConnection()
-                txn.autoCommit = false
+                connection = createConnection()
+                connection.autoCommit = false
                 connectionsLock.lock()
                 try {
                     openConnections++
@@ -186,15 +215,15 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
         } catch (e: SQLException) {
             throw DbException(e)
         }
-        return txn
+        return connection
     }
 
-    override fun abortTransaction(txn: Connection) {
+    private fun abortTransaction(connection: Connection) {
         try {
-            txn.rollback()
+            connection.rollback()
             connectionsLock.lock()
             try {
-                connections.add(txn)
+                connections.add(connection)
                 connectionsChanged.signalAll()
             } finally {
                 connectionsLock.unlock()
@@ -202,7 +231,7 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
         } catch (e: SQLException) {
             // Try to close the connection
             logException(LOG, e)
-            tryToClose(txn, LOG)
+            tryToClose(connection, LOG)
             // Whatever happens, allow the database to close
             connectionsLock.lock()
             try {
@@ -214,15 +243,15 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
         }
     }
 
-    override fun commitTransaction(txn: Connection) {
+    private fun commitTransaction(connection: Connection) {
         try {
-            txn.commit()
+            connection.commit()
         } catch (e: SQLException) {
             throw DbException(e)
         }
         connectionsLock.lock()
         try {
-            connections.add(txn)
+            connections.add(connection)
             connectionsChanged.signalAll()
         } finally {
             connectionsLock.unlock()
@@ -260,10 +289,10 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    fun setDirty(txn: Connection, dirty: Boolean) {
+    fun setDirty(connection: Connection, dirty: Boolean) {
         val s = Settings()
         s.putBoolean(DIRTY_KEY, dirty)
-        mergeSettings(txn, s, DB_SETTINGS_NAMESPACE)
+        mergeSettings(connection, s, DB_SETTINGS_NAMESPACE)
     }
 
     private fun isCompactionDue(s: Settings): Boolean {
@@ -274,32 +303,32 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    private fun storeSchemaVersion(txn: Connection, version: Int) {
+    private fun storeSchemaVersion(connection: Connection, version: Int) {
         val s = Settings()
         s.putInt(SCHEMA_VERSION_KEY, version)
-        mergeSettings(txn, s, DB_SETTINGS_NAMESPACE)
+        mergeSettings(connection, s, DB_SETTINGS_NAMESPACE)
     }
 
     @Throws(DbException::class)
-    private fun storeLastCompacted(txn: Connection) {
+    private fun storeLastCompacted(connection: Connection) {
         val s = Settings()
         s.putLong(LAST_COMPACTED_KEY, clock.currentTimeMillis())
-        mergeSettings(txn, s, DB_SETTINGS_NAMESPACE)
+        mergeSettings(connection, s, DB_SETTINGS_NAMESPACE)
     }
 
     @Throws(DbException::class)
-    private fun initialiseSettings(txn: Connection) {
+    private fun initialiseSettings(connection: Connection) {
         val s = Settings()
         s.putInt(SCHEMA_VERSION_KEY, CODE_SCHEMA_VERSION)
         s.putLong(LAST_COMPACTED_KEY, clock.currentTimeMillis())
-        mergeSettings(txn, s, DB_SETTINGS_NAMESPACE)
+        mergeSettings(connection, s, DB_SETTINGS_NAMESPACE)
     }
 
     @Throws(DbException::class)
-    private fun createTables(txn: Connection) {
+    private fun createTables(connection: Connection) {
         var s: Statement? = null
         try {
-            s = txn.createStatement()
+            s = connection.createStatement()
             s.executeUpdate(dbTypes.replaceTypes(CREATE_SETTINGS))
             s.executeUpdate(dbTypes.replaceTypes(CREATE_CONTACTS))
             s.close()
@@ -310,10 +339,10 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    private fun createIndexes(txn: Connection) {
+    private fun createIndexes(connection: Connection) {
         var s: Statement? = null
         try {
-            s = txn.createStatement()
+            s = connection.createStatement()
             // s.executeUpdate(INDEX_SOMETABLE_BY_SOMECOLUMN)
             s.close()
         } catch (e: SQLException) {
@@ -323,7 +352,13 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    override fun getSettings(txn: Connection, namespace: String?): Settings {
+    override fun getSettings(txn: Transaction, namespace: String): Settings {
+        val connection: Connection = txn.unbox() as Connection
+        return getSettings(connection, namespace)
+    }
+
+    @Throws(DbException::class)
+    private fun getSettings(connection: Connection, namespace: String): Settings {
         var ps: PreparedStatement? = null
         var rs: ResultSet? = null
         return try {
@@ -332,7 +367,7 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
                        WHERE namespace = ?
             """.trimIndent()
 
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             ps.setString(1, namespace)
             rs = ps.executeQuery()
             val s = Settings()
@@ -348,7 +383,13 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    override fun mergeSettings(txn: Connection, s: Settings, namespace: String?) {
+    override fun mergeSettings(txn: Transaction, s: Settings, namespace: String) {
+        val connection: Connection = txn.unbox() as Connection
+        mergeSettings(connection, s, namespace)
+    }
+
+    @Throws(DbException::class)
+    fun mergeSettings(connection: Connection, s: Settings, namespace: String) {
         var ps: PreparedStatement? = null
         try {
             // Update any settings that already exist
@@ -357,7 +398,7 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
                        WHERE namespace = ? AND settingKey = ?
             """.trimIndent()
 
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             for ((key, value) in s) {
                 ps.setString(1, value)
                 ps.setString(2, namespace)
@@ -376,7 +417,7 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
                             VALUES (?, ?, ?)
             """.trimIndent()
 
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             var updateIndex = 0
             var inserted = 0
             for ((key, value) in s) {
@@ -400,13 +441,14 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    override fun addContact(txn: Connection, contact: Contact) {
+    override fun addContact(txn: Transaction, contact: Contact) {
+        val connection: Connection = txn.unbox() as Connection
         var ps: PreparedStatement? = null
         try {
             val sql = """INSERT INTO contacts (contactId, token, inbox, outbox)
                                 VALUES (?, ?, ?, ?)
             """.trimIndent()
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             ps.setInt(1, contact.id)
             ps.setString(2, contact.token)
             ps.setString(3, contact.inboxId)
@@ -421,14 +463,15 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    override fun getContact(txn: Connection, id: Int): Contact? {
+    override fun getContact(txn: Transaction, id: Int): Contact? {
+        val connection: Connection = txn.unbox() as Connection
         var ps: PreparedStatement? = null
         var rs: ResultSet? = null
         try {
             val sql = """SELECT token, inbox, outbox FROM contacts
                                 WHERE contactId = ?
             """.trimIndent()
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             ps.setInt(1, id)
             rs = ps.executeQuery()
             if (!rs.next()) return null
@@ -446,11 +489,12 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
     }
 
     @Throws(DbException::class)
-    override fun removeContact(txn: Connection, id: Int) {
+    override fun removeContact(txn: Transaction, id: Int) {
+        val connection: Connection = txn.unbox() as Connection
         var ps: PreparedStatement? = null
         try {
             val sql = "DELETE FROM contacts WHERE contactId = ?"
-            ps = txn.prepareStatement(sql)
+            ps = connection.prepareStatement(sql)
             ps.setInt(1, id)
             val affected = ps.executeUpdate()
             if (affected != 1) throw DbStateException()
@@ -461,4 +505,53 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc
         }
     }
 
+    /**
+     * Commits a transaction to the database.
+     */
+    @Throws(DbException::class)
+    private fun commitTransaction(txn: Transaction) {
+        val connection: Connection = txn.unbox() as Connection
+        check(!txn.isCommitted)
+        txn.setCommitted()
+        commitTransaction(connection)
+    }
+
+    /**
+     * Ends a transaction. If the transaction has not been committed,
+     * it will be aborted. If the transaction has been committed,
+     * any events attached to the transaction are broadcast.
+     * The database lock will be released in either case.
+     */
+    private fun endTransaction(txn: Transaction) {
+        try {
+            val connection: Connection = txn.unbox() as Connection
+            if (!txn.isCommitted) {
+                abortTransaction(connection)
+            }
+        } finally {
+            if (txn.isReadOnly) lock.readLock().unlock() else lock.writeLock().unlock()
+        }
+    }
+
+    override fun transaction(readOnly: Boolean, task: (Transaction) -> Unit) {
+        val txn = startTransaction(readOnly)
+        try {
+            task(txn)
+            commitTransaction(txn)
+        } finally {
+            endTransaction(txn)
+        }
+    }
+
+    override fun <R> transactionWithResult(readOnly: Boolean, task: (Transaction) -> R): R {
+        val txn = startTransaction(readOnly)
+        try {
+            val result = task(txn)
+            commitTransaction(txn)
+            return result
+        } finally {
+            endTransaction(txn)
+        }
+    }
+
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Transaction.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Transaction.kt
new file mode 100644
index 0000000000000000000000000000000000000000..81176661a8f34e4d72cc40f9c5860be56ba19748
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Transaction.kt
@@ -0,0 +1,35 @@
+package org.briarproject.mailbox.core.db
+
+import java.sql.Connection
+
+class Transaction(
+    private val txn: Connection,
+    /**
+     * Returns true if the transaction can only be used for reading.
+     */
+    val isReadOnly: Boolean,
+) {
+
+    /**
+     * Returns true if the transaction has been committed.
+     */
+    var isCommitted = false
+        private set
+
+    /**
+     * Returns the database transaction. The type of the returned object
+     * depends on the database implementation.
+     */
+    fun unbox(): Connection {
+        return txn
+    }
+
+    /**
+     * Marks the transaction as committed. This method should only be called
+     * by the DatabaseComponent. It must not be called more than once.
+     */
+    fun setCommitted() {
+        check(!isCommitted)
+        isCommitted = true
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/TransactionManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/TransactionManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d663509b2d682e9d163075dd35ea438eb21f3ac8
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/TransactionManager.kt
@@ -0,0 +1,17 @@
+package org.briarproject.mailbox.core.db
+
+interface TransactionManager {
+
+    /**
+     * Runs the given task within a transaction.
+     */
+    @Throws(DbException::class)
+    fun transaction(readOnly: Boolean, task: (Transaction) -> Unit)
+
+    /**
+     * Runs the given task within a transaction and returns the result of the
+     * task.
+     */
+    @Throws(DbException::class)
+    fun <R> transactionWithResult(readOnly: Boolean, task: (Transaction) -> R): R
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
index 63719354388f2fd8af55660a4e5ddf476b9219a8..2e8aa3ee71e0895b2cc056f5935972850c7482d0 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManager.java
@@ -1,6 +1,6 @@
 package org.briarproject.mailbox.core.lifecycle;
 
-import org.briarproject.mailbox.core.db.DatabaseComponent;
+import org.briarproject.mailbox.core.db.Database;
 import org.briarproject.mailbox.core.system.Wakeful;
 
 import java.util.concurrent.ExecutorService;
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.StateFlow;
 
 /**
  * Manages the lifecycle of the app: opening and closing the
- * {@link DatabaseComponent} starting and stopping {@link Service Services},
+ * {@link Database} starting and stopping {@link Service Services},
  * and shutting down {@link ExecutorService ExecutorServices}.
  */
 public interface LifecycleManager {
@@ -29,8 +29,13 @@ public interface LifecycleManager {
      */
     enum LifecycleState {
 
-        STOPPED, STARTING, MIGRATING_DATABASE, COMPACTING_DATABASE, STARTING_SERVICES,
-        RUNNING, STOPPING;
+        STOPPED,
+        STARTING,
+        MIGRATING_DATABASE,
+        COMPACTING_DATABASE,
+        STARTING_SERVICES,
+        RUNNING,
+        STOPPING;
 
         public boolean isAfter(LifecycleState state) {
             return ordinal() > state.ordinal();
@@ -57,7 +62,7 @@ public interface LifecycleManager {
     void registerForShutdown(ExecutorService e);
 
     /**
-     * Opens the {@link DatabaseComponent} using the given key and starts any
+     * Opens the {@link Database} using the given key and starts any
      * registered {@link Service Services}.
      */
     @Wakeful
@@ -66,18 +71,18 @@ public interface LifecycleManager {
     /**
      * Stops any registered {@link Service Services}, shuts down any
      * registered {@link ExecutorService ExecutorServices}, and closes the
-     * {@link DatabaseComponent}.
+     * {@link Database}.
      */
     @Wakeful
     void stopServices();
 
     /**
-     * Waits for the {@link DatabaseComponent} to be opened before returning.
+     * Waits for the {@link Database} to be opened before returning.
      */
     void waitForDatabase() throws InterruptedException;
 
     /**
-     * Waits for the {@link DatabaseComponent} to be opened and all registered
+     * Waits for the {@link Database} to be opened and all registered
      * {@link Service Services} to start before returning.
      */
     void waitForStartup() throws InterruptedException;
@@ -85,7 +90,7 @@ public interface LifecycleManager {
     /**
      * Waits for all registered {@link Service Services} to stop, all
      * registered {@link ExecutorService ExecutorServices} to shut down, and
-     * the {@link DatabaseComponent} to be closed before returning.
+     * the {@link Database} to be closed before returning.
      */
     void waitForShutdown() throws InterruptedException;
 
@@ -104,4 +109,4 @@ public interface LifecycleManager {
         @Wakeful
         void onDatabaseOpened();
     }
-}
\ No newline at end of file
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
index d5e659d0c5c455419179f82bb52ea561e8edd515..1ab9b3549ed4d6196c59bee7807a8f02c49d6f72 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/lifecycle/LifecycleManagerImpl.kt
@@ -2,7 +2,7 @@ package org.briarproject.mailbox.core.lifecycle
 
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
-import org.briarproject.mailbox.core.db.DatabaseComponent
+import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.db.MigrationListener
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager.LifecycleState.COMPACTING_DATABASE
@@ -31,7 +31,7 @@ import javax.annotation.concurrent.ThreadSafe
 import javax.inject.Inject
 
 @ThreadSafe
-internal class LifecycleManagerImpl @Inject constructor(private val db: DatabaseComponent) :
+internal class LifecycleManagerImpl @Inject constructor(private val db: Database) :
     LifecycleManager, MigrationListener {
 
     companion object {
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..691517279963a38a37f96fc82337859ae76e34b4
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManager.kt
@@ -0,0 +1,25 @@
+package org.briarproject.mailbox.core.settings
+
+import org.briarproject.mailbox.core.db.DbException
+import org.briarproject.mailbox.core.db.Transaction
+
+interface SettingsManager {
+    /**
+     * Returns all settings in the given namespace.
+     */
+    @Throws(DbException::class)
+    fun getSettings(namespace: String): Settings
+
+    /**
+     * Returns all settings in the given namespace.
+     */
+    @Throws(DbException::class)
+    fun getSettings(txn: Transaction, namespace: String): Settings
+
+    /**
+     * Merges the given settings with any existing settings in the given
+     * namespace.
+     */
+    @Throws(DbException::class)
+    fun mergeSettings(s: Settings, namespace: String)
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c1ccc66733d9d8efd8f9782d23b742db83d6caf2
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsManagerImpl.kt
@@ -0,0 +1,28 @@
+package org.briarproject.mailbox.core.settings
+
+import org.briarproject.mailbox.core.db.Database
+import org.briarproject.mailbox.core.db.DbException
+import org.briarproject.mailbox.core.db.Transaction
+import javax.annotation.concurrent.Immutable
+import javax.inject.Inject
+
+@Immutable
+internal class SettingsManagerImpl @Inject constructor(private val db: Database) : SettingsManager {
+
+    @Throws(DbException::class)
+    override fun getSettings(namespace: String): Settings {
+        return db.transactionWithResult(true) { txn ->
+            db.getSettings(txn, namespace)
+        }
+    }
+
+    @Throws(DbException::class)
+    override fun getSettings(txn: Transaction, namespace: String): Settings {
+        return db.getSettings(txn, namespace)
+    }
+
+    @Throws(DbException::class)
+    override fun mergeSettings(s: Settings, namespace: String) {
+        db.transaction(false) { txn -> db.mergeSettings(txn, s, namespace) }
+    }
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..75490e09ff388f8b27c6bd5873d244c7cdbea4fd
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/SettingsModule.kt
@@ -0,0 +1,13 @@
+package org.briarproject.mailbox.core.settings
+
+import dagger.Module
+import dagger.Provides
+import org.briarproject.mailbox.core.db.Database
+
+@Module
+class SettingsModule {
+    @Provides
+    fun provideSettingsManager(db: Database): SettingsManager {
+        return SettingsManagerImpl(db)
+    }
+}
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
index b41ac5c317e00b69e523b35112fb64ca5ee01b89..2a9abd05bca1b569c945e5ae0cc888f6bef197ec 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
@@ -1,7 +1,9 @@
 package org.briarproject.mailbox.core
 
 import dagger.Component
+import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
+import org.briarproject.mailbox.core.settings.SettingsManager
 import javax.inject.Singleton
 
 @Singleton
@@ -13,4 +15,6 @@ import javax.inject.Singleton
 interface TestComponent {
     fun injectCoreEagerSingletons(): CoreEagerSingletons
     fun getLifecycleManager(): LifecycleManager
+    fun getSettingsManager(): SettingsManager
+    fun getDatabase(): Database
 }
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
index 38d46db1e289328a5f3f4fe3b4394327f2d043e0..20b9590c42713f4fc9ec144fbc436cdd995ec4db 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestModule.kt
@@ -8,6 +8,7 @@ import org.briarproject.mailbox.core.db.DatabaseConfig
 import org.briarproject.mailbox.core.db.DatabaseModule
 import org.briarproject.mailbox.core.lifecycle.LifecycleModule
 import org.briarproject.mailbox.core.server.WebServerModule
+import org.briarproject.mailbox.core.settings.SettingsModule
 import org.briarproject.mailbox.core.system.Clock
 import java.io.File
 import javax.inject.Singleton
@@ -17,6 +18,7 @@ import javax.inject.Singleton
         LifecycleModule::class,
         DatabaseModule::class,
         WebServerModule::class,
+        SettingsModule::class,
         // no Tor module
     ]
 )
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/db/JdbcDatabaseTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/db/JdbcDatabaseTest.kt
index f2e0018ab7b6a39312c5fc3b8d001a4f801d1bce..b711dcb000d1a6138ed7bf1cd3609a2963131d9c 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/db/JdbcDatabaseTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/db/JdbcDatabaseTest.kt
@@ -2,11 +2,11 @@ package org.briarproject.mailbox.core.db
 
 import org.briarproject.mailbox.core.TestUtils.deleteTestDirectory
 import org.briarproject.mailbox.core.api.Contact
+import org.briarproject.mailbox.core.settings.Settings
 import org.briarproject.mailbox.core.system.Clock
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.io.TempDir
 import java.io.File
-import java.sql.Connection
 import kotlin.test.assertEquals
 import kotlin.test.assertNull
 
@@ -23,8 +23,8 @@ abstract class JdbcDatabaseTest {
     @Throws(java.lang.Exception::class)
     fun open(
         resume: Boolean,
-    ): Database<Connection> {
-        val db: Database<Connection> = createDatabase(
+    ): Database {
+        val db: Database = createDatabase(
             TestDatabaseConfig(testDir)
         ) { System.currentTimeMillis() }
         if (!resume) deleteTestDirectory(testDir)
@@ -36,9 +36,6 @@ abstract class JdbcDatabaseTest {
     @Throws(Exception::class)
     open fun testPersistence() {
         // Store some records
-        var db: Database<Connection> = open(false)
-        var txn = db.startTransaction()
-
         val contact1 = Contact(
             1,
             "4291ad1d-897d-4db4-9de9-ea3f78c5262e",
@@ -51,38 +48,60 @@ abstract class JdbcDatabaseTest {
             "7931fa7a-077e-403a-8487-63261027d6d2",
             "12a61ca3-af0a-41d1-acc1-a0f4625f6e42"
         )
+        var db: Database = open(false)
+        db.transaction(false) { txn ->
 
-        db.addContact(txn, contact1)
-        db.addContact(txn, contact2)
-
-        db.commitTransaction(txn)
+            db.addContact(txn, contact1)
+            db.addContact(txn, contact2)
+        }
         db.close()
 
         // Check that the records are still there
         db = open(true)
-        txn = db.startTransaction()
-
-        val contact1Reloaded1 = db.getContact(txn, 1)
-        val contact2Reloaded1 = db.getContact(txn, 2)
-        assertEquals(contact1, contact1Reloaded1)
-        assertEquals(contact2, contact2Reloaded1)
-
-        // Delete one of the records
-        db.removeContact(txn, 1)
-
-        db.commitTransaction(txn)
+        db.transaction(false) { txn ->
+            val contact1Reloaded1 = db.getContact(txn, 1)
+            val contact2Reloaded1 = db.getContact(txn, 2)
+            assertEquals(contact1, contact1Reloaded1)
+            assertEquals(contact2, contact2Reloaded1)
+
+            // Delete one of the records
+            db.removeContact(txn, 1)
+        }
         db.close()
 
         // Check that the record is gone
         db = open(true)
-        txn = db.startTransaction()
-
-        val contact1Reloaded2 = db.getContact(txn, 1)
-        val contact2Reloaded2 = db.getContact(txn, 2)
-        assertNull(contact1Reloaded2)
-        assertEquals(contact2, contact2Reloaded2)
+        db.transaction(true) { txn ->
+            val contact1Reloaded2 = db.getContact(txn, 1)
+            val contact2Reloaded2 = db.getContact(txn, 2)
+            assertNull(contact1Reloaded2)
+            assertEquals(contact2, contact2Reloaded2)
+        }
+        db.close()
+    }
 
-        db.commitTransaction(txn)
+    @Test
+    @Throws(java.lang.Exception::class)
+    open fun testMergeSettings() {
+        val before = Settings()
+        before["foo"] = "bar"
+        before["baz"] = "bam"
+        val update = Settings()
+        update["baz"] = "qux"
+        val merged = Settings()
+        merged["foo"] = "bar"
+        merged["baz"] = "qux"
+
+        var db: Database = open(false)
+        var txn = db.transaction(false) { txn ->
+            // store 'before'
+            db.mergeSettings(txn, before, "namespace")
+            assertEquals(before, db.getSettings(txn, "namespace"))
+
+            // merge 'update'
+            db.mergeSettings(txn, update, "namespace")
+            assertEquals(merged, db.getSettings(txn, "namespace"))
+        }
         db.close()
     }
 
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/SettingsManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/SettingsManagerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3b2bfb542ec32e88eb02307334032e797eb4358c
--- /dev/null
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/settings/SettingsManagerTest.kt
@@ -0,0 +1,49 @@
+package org.briarproject.mailbox.core.settings
+
+import org.briarproject.mailbox.core.DaggerTestComponent
+import org.briarproject.mailbox.core.TestComponent
+import org.briarproject.mailbox.core.TestModule
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.TestInstance.Lifecycle
+import org.junit.jupiter.api.io.TempDir
+import java.io.File
+import kotlin.test.assertEquals
+
+@TestInstance(Lifecycle.PER_CLASS)
+class SettingsManagerTest {
+
+    private lateinit var testComponent: TestComponent
+
+    @BeforeAll
+    fun setUp(@TempDir tempDir: File) {
+        testComponent = DaggerTestComponent.builder().testModule(TestModule(tempDir)).build()
+        testComponent.injectCoreEagerSingletons()
+        testComponent.getDatabase().open(null)
+    }
+
+    @Test
+    @Throws(java.lang.Exception::class)
+    open fun testMergeSettings() {
+        val before = Settings()
+        before["foo"] = "bar"
+        before["baz"] = "bam"
+        val update = Settings()
+        update["baz"] = "qux"
+        val merged = Settings()
+        merged["foo"] = "bar"
+        merged["baz"] = "qux"
+
+        val sm: SettingsManager = testComponent.getSettingsManager()
+
+        // store 'before'
+        sm.mergeSettings(before, "namespace")
+        assertEquals(before, sm.getSettings("namespace"))
+
+        // merge 'update'
+        sm.mergeSettings(update, "namespace")
+        assertEquals(merged, sm.getSettings("namespace"))
+    }
+
+}