diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py
index 54b1aac2d..a29659160 100644
--- a/securedrop_client/gui/__init__.py
+++ b/securedrop_client/gui/__init__.py
@@ -17,9 +17,14 @@
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 """
 from typing import Union
+import math
+import html
+import sys
 
 from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton, QWidget
+from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings
 from PyQt5.QtCore import QSize, Qt
+from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent
 
 from securedrop_client.resources import load_svg, load_icon
 
@@ -195,3 +200,94 @@ def get_elided_text(self, full_text: str) -> str:
 
     def is_elided(self) -> bool:
         return self.elided
+
+
+class SecureQPlainTextEdit(QWebEngineView):
+    '''
+    This is a resizeable widget used to display read-only plain text content.
+
+    Like `SecureQLabel`, this widget wraps lines of text. Unlike `SecureQLabel`, this also supports
+    wrapping text which contains no spaces or dashes and is longer than the width of the widget.
+
+    Like `SecureQLabel`, this automatically resizes the widget height to fit its content. Since this
+    is not a supported Qt feature for QPlainTextEdit (`self.size().height()` returns a fixed
+    default pixel height instead of a content-fitted height no matter what our sizePolicy is, and
+    `self.document().size().height()` returns the number of blocks in the document), we calculate
+    and set the height when `setPlainText` is called.
+    '''
+    HEIGHT_BASE = 60
+    LINE_HEIGHT = 20
+    FONT_NAME = 'Source Sans Pro'
+    FONT_SIZE = 15
+    TEMPLATE_START = """<html>
+    <head>
+        <style>
+            .container {
+                width: 515px;
+                background-color: white;
+                white-space: normal;
+                overflow-wrap: break-word;
+                word-wrap: break-word;
+                word-break: break-word;
+                -ms-word-break: break-all;
+                -ms-hyphens: auto;
+                -moz-hyphens: auto;
+                -webkit-hyphens: auto;
+                hyphens: auto;
+                white-space: pre-line;
+            }
+        </style>
+
+    </head>
+    <body>
+        <div    class="container">
+"""
+    TEMPLATE_END = """
+        </div>
+    </body>
+</html>"""
+
+    def __init__(self, text: str = '') -> None:
+        super().__init__()
+        #self.setReadOnly(True)  # Do not allow SecureQPlainTextEdit to be editable
+        self.setContextMenuPolicy(Qt.NoContextMenu)  # Disable copy/paste context menu
+        #self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        self.setFont(QFont(self.FONT_NAME, self.FONT_SIZE))
+        #self.height = self.HEIGHT_BASE
+        self.iText = ""
+        self.resize(580, 100)
+        self.setPlainText(text)
+
+    def setText(self, text):
+        self.setPlainText(text)
+
+    def toPlainText(self):
+        return self.iText
+
+    def setPlainText(self, text: str) -> None:
+        self.iText = text
+        settings = self.settings()
+        settings.setAttribute(QWebEngineSettings.JavascriptEnabled, False)
+        settings.setAttribute(QWebEngineSettings.AutoLoadImages, False)
+
+        escaped = html.escape(text)
+        # replace tab char with 8 spaces because HTML
+        escaped = escaped.replace("\t", "&nbsp;" * 8)
+        super().setHtml(self.TEMPLATE_START + escaped + self.TEMPLATE_END)
+        page = self.page()
+        size = page.contentsSize()
+        page.contentsSizeChanged.connect(self.calculate)
+
+
+    def calculate(self, size):
+        self.setFixedHeight(size.height())
+
+    def mouseReleaseEvent(self, event: QMouseEvent) -> None:
+        self.setFocus()
+        super().mouseReleaseEvent(event)
+
+    def focusOutEvent(self, event: QFocusEvent) -> None:
+        clear_text_cursor = self.textCursor()
+        clear_text_cursor.clearSelection()
+        self.setTextCursor(clear_text_cursor)
+        super().focusOutEvent(event)
diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py
index 2612bda8f..9ad4f9124 100644
--- a/securedrop_client/gui/widgets.py
+++ b/securedrop_client/gui/widgets.py
@@ -38,7 +38,8 @@
 from securedrop_client.db import DraftReply, Source, Message, File, Reply, User
 from securedrop_client.storage import source_exists
 from securedrop_client.export import ExportStatus, ExportError
-from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton
+from securedrop_client.gui import SecureQLabel, SecureQPlainTextEdit, SvgLabel, SvgPushButton, \
+    SvgToggleButton
 from securedrop_client.logic import Controller
 from securedrop_client.resources import load_icon, load_image, load_movie
 from securedrop_client.utils import humanize_filesize
@@ -1898,7 +1899,8 @@ class SpeechBubble(QWidget):
         font-weight: 400;
         font-size: 15px;
         background-color: #fff;
-        padding: 16px;
+        padding: 24px;
+        border: none;
     }
     #color_bar {
         min-height: 5px;
@@ -1918,7 +1920,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N
 
         # Set styles
         self.setStyleSheet(self.CSS)
-        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
 
         # Set layout
         layout = QVBoxLayout()
@@ -1929,7 +1931,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N
         layout.setSpacing(0)
 
         # Message box
-        self.message = SecureQLabel(text)
+        self.message = SecureQPlainTextEdit(text)
         self.message.setObjectName('message')
 
         # Color bar
@@ -1937,14 +1939,16 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N
         self.color_bar.setObjectName('color_bar')
 
         # Speech bubble
-        speech_bubble = QWidget()
-        speech_bubble.setObjectName('speech_bubble')
+        self.speech_bubble = QWidget()
+        self.speech_bubble.setObjectName('speech_bubble')
         speech_bubble_layout = QVBoxLayout()
-        speech_bubble.setLayout(speech_bubble_layout)
+        self.speech_bubble.setLayout(speech_bubble_layout)
         speech_bubble_layout.addWidget(self.message)
         speech_bubble_layout.addWidget(self.color_bar)
         speech_bubble_layout.setContentsMargins(0, 0, 0, 0)
         speech_bubble_layout.setSpacing(0)
+        # self.speech_bubble.setFixedHeight(self.message.height)
+        self.speech_bubble.adjustSize()
 
         # Bubble area includes speech bubble plus error message if there is an error
         bubble_area = QWidget()
@@ -1952,7 +1956,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N
         self.bubble_area_layout = QHBoxLayout()
         self.bubble_area_layout.setContentsMargins(0, self.TOP_MARGIN, 0, self.BOTTOM_MARGIN)
         bubble_area.setLayout(self.bubble_area_layout)
-        self.bubble_area_layout.addWidget(speech_bubble)
+        self.bubble_area_layout.addWidget(self.speech_bubble)
 
         # Add widget to layout
         layout.addWidget(bubble_area)
@@ -1967,7 +1971,9 @@ def _update_text(self, source_id: str, message_uuid: str, text: str) -> None:
         signal matches the uuid of this speech bubble.
         """
         if message_uuid == self.uuid:
-            self.message.setText(text)
+            self.message.setPlainText(text)
+            # self.speech_bubble.setFixedHeight(self.message.height)
+            self.speech_bubble.adjustSize()
 
 
 class MessageWidget(SpeechBubble):
@@ -1990,7 +1996,7 @@ class ReplyWidget(SpeechBubble):
         font-size: 15px;
         background-color: #fff;
         color: #3b3b3b;
-        padding: 16px;
+        padding: 24px;
     '''
 
     CSS_COLOR_BAR_REPLY_FAILED = '''
@@ -2013,7 +2019,7 @@ class ReplyWidget(SpeechBubble):
         font-size: 15px;
         background-color: #fff;
         color: #3b3b3b;
-        padding: 16px;
+        padding: 24px;
     '''
 
     CSS_COLOR_BAR_REPLY_SUCCEEDED = '''
@@ -2029,7 +2035,7 @@ class ReplyWidget(SpeechBubble):
         font-size: 15px;
         color: #A9AAAD;
         background-color: #F7F8FC;
-        padding: 16px;
+        padding: 24px;
     '''
 
     CSS_COLOR_BAR_REPLY_PENDING = '''
@@ -3135,7 +3141,7 @@ def update_conversation(self, collection: list) -> None:
                 # Check if text in item has changed, then update the
                 # widget to reflect this change.
                 if not isinstance(item_widget, FileWidget):
-                    if (item_widget.message.text() != conversation_item.content) and \
+                    if (item_widget.message.toPlainText() != conversation_item.content) and \
                             conversation_item.content:
                         item_widget.message.setText(conversation_item.content)
             else:
diff --git a/tests/functional/test_download_file.py b/tests/functional/test_download_file.py
index 9a5e02d98..1e4593656 100644
--- a/tests/functional/test_download_file.py
+++ b/tests/functional/test_download_file.py
@@ -44,7 +44,7 @@ def check_for_sources():
     # We see the source's message.
     last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2]
     last_msg = conversation.conversation_view.current_messages[last_msg_id]
-    assert last_msg.message.text() == message
+    assert last_msg.message.toPlainText() == message
 
     # Let us download the file
     qtbot.mouseClick(file_msg.download_button, Qt.LeftButton)
diff --git a/tests/functional/test_export_dialog.py b/tests/functional/test_export_dialog.py
index fb76d3e97..8eff0e074 100644
--- a/tests/functional/test_export_dialog.py
+++ b/tests/functional/test_export_dialog.py
@@ -44,7 +44,7 @@ def check_for_sources():
     # We see the source's message.
     last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2]
     last_msg = conversation.conversation_view.current_messages[last_msg_id]
-    assert last_msg.message.text() == message
+    assert last_msg.message.toPlainText() == message
 
     # Let us download the file
     qtbot.mouseClick(file_msg.download_button, Qt.LeftButton)
diff --git a/tests/functional/test_receive_message.py b/tests/functional/test_receive_message.py
index 8014f2287..637dc0b43 100644
--- a/tests/functional/test_receive_message.py
+++ b/tests/functional/test_receive_message.py
@@ -44,4 +44,4 @@ def check_for_sources():
     # We see the source's message.
     last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2]
     last_msg = conversation.conversation_view.current_messages[last_msg_id]
-    assert last_msg.message.text() == message
+    assert last_msg.message.toPlainText() == message
diff --git a/tests/functional/test_send_reply.py b/tests/functional/test_send_reply.py
index f236fa505..ce1cafa56 100644
--- a/tests/functional/test_send_reply.py
+++ b/tests/functional/test_send_reply.py
@@ -47,4 +47,4 @@ def check_for_sources():
     # just typed.
     last_msg_id = list(conversation.conversation_view.current_messages.keys())[-1]
     last_msg = conversation.conversation_view.current_messages[last_msg_id]
-    assert last_msg.message.text() == message
+    assert last_msg.message.toPlainText() == message
diff --git a/tests/gui/test_init.py b/tests/gui/test_init.py
index a16096379..853f849a9 100644
--- a/tests/gui/test_init.py
+++ b/tests/gui/test_init.py
@@ -1,10 +1,12 @@
 """
 Tests for the gui helper functions in __init__.py
 """
-from PyQt5.QtCore import QSize, Qt
+from PyQt5.QtCore import QSize, Qt, QEvent, QPoint
+from PyQt5.QtGui import QFocusEvent, QMouseEvent
 from PyQt5.QtWidgets import QApplication
 
-from securedrop_client.gui import SecureQLabel, SvgPushButton, SvgLabel, SvgToggleButton
+from securedrop_client.gui import (SecureQLabel, SecureQPlainTextEdit,
+                                   SvgPushButton, SvgLabel, SvgToggleButton)
 
 app = QApplication([])
 
@@ -185,3 +187,47 @@ def test_SecureQLabel_get_elided_text_only_returns_oneline_elided(mocker):
 def test_SecureQLabel_quotes_not_escaped_for_readability():
     sl = SecureQLabel("'hello'")
     assert sl.text() == "'hello'"
+
+
+def test_SecureQPlainTextEdit_init():
+    label_text = '<script>alert("hi!");</script>'
+    sl = SecureQPlainTextEdit(label_text)
+    assert sl.toPlainText() == label_text
+
+
+def test_SecureQPlainTextEdit_quotes_not_escaped_for_readability():
+    label_text = "'hello'"
+    sl = SecureQPlainTextEdit(label_text)
+    assert sl.toPlainText() == label_text
+
+
+def test_SecureQPlainTextEdit_init_wordwrap():
+    '''
+    Regression test to make sure we don't remove newlines.
+    '''
+    long_string = ('1234567890123456789012345678901234567890123456789012345678901234567890\n'
+                   '12345678901')
+    sl = SecureQPlainTextEdit(long_string)
+    assert sl.toPlainText() == long_string
+
+
+def test_SecureQPlainTextEdit_mouse_release_event_sets_focus(mocker):
+    dummy_number = 1
+    test_event = QMouseEvent(QEvent.MouseButtonRelease, QPoint(dummy_number, dummy_number),
+                             Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
+    sl = SecureQPlainTextEdit("foo")
+    sl.setFocus = mocker.MagicMock()
+
+    sl.mouseReleaseEvent(test_event)
+
+    assert sl.setFocus.call_count == 1
+
+
+def test_SecureQPlainTextEdit_focus_out_removes_text_selection(mocker):
+    focus_out_event = QFocusEvent(QEvent.FocusOut)
+    sl = SecureQPlainTextEdit("foo")
+    sl.setTextCursor = mocker.MagicMock()
+
+    sl.focusOutEvent(focus_out_event)
+
+    assert sl.setTextCursor.call_count == 1
diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py
index 5fcd0d2f1..f63424314 100644
--- a/tests/gui/test_widgets.py
+++ b/tests/gui/test_widgets.py
@@ -2031,7 +2031,7 @@ def test_SpeechBubble_init(mocker):
     sb = SpeechBubble('mock id', 'hello', mock_signal, 0)
     ss = sb.styleSheet()
 
-    sb.message.text() == 'hello'
+    sb.message.toPlainText() == 'hello'
     assert mock_connect.called
     assert 'background-color' in ss
 
@@ -2047,11 +2047,11 @@ def test_SpeechBubble_update_text(mocker):
 
     new_msg = 'new message'
     sb._update_text('mock_source_uuid', msg_id, new_msg)
-    assert sb.message.text() == new_msg
+    assert sb.message.toPlainText() == new_msg
 
     newer_msg = 'an even newer message'
     sb._update_text('mock_source_uuid', msg_id + 'xxxxx', newer_msg)
-    assert sb.message.text() == new_msg
+    assert sb.message.toPlainText() == new_msg
 
 
 def test_SpeechBubble_html_init(mocker):
@@ -2062,7 +2062,7 @@ def test_SpeechBubble_html_init(mocker):
     mock_signal = mocker.MagicMock()
 
     bubble = SpeechBubble('mock id', '<b>hello</b>', mock_signal, 0)
-    assert bubble.message.text() == '<b>hello</b>'
+    assert bubble.message.toPlainText() == '<b>hello</b>'
 
 
 def test_SpeechBubble_with_apostrophe_in_text(mocker):
@@ -2071,7 +2071,7 @@ def test_SpeechBubble_with_apostrophe_in_text(mocker):
 
     message = "I'm sure, you are reading my message."
     bubble = SpeechBubble('mock id', message, mock_signal, 0)
-    assert bubble.message.text() == message
+    assert bubble.message.toPlainText() == message
 
 
 def test_MessageWidget_init(mocker):