diff --git a/res/css/views/elements/_SettingsFlag.scss b/res/css/views/elements/_SettingsFlag.scss
index 533487d98c..c6f4cf6ec5 100644
--- a/res/css/views/elements/_SettingsFlag.scss
+++ b/res/css/views/elements/_SettingsFlag.scss
@@ -41,4 +41,10 @@ limitations under the License.
     font-size: $font-12px;
     line-height: $font-15px;
     color: $secondary-content;
+
+    // Support code/pre elements in settings flag descriptions
+    pre, code {
+        font-family: $monospace-font-family !important;
+        background-color: $rte-code-bg-color;
+    }
 }
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index a5dcf03813..e93119643f 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -103,6 +103,7 @@ interface IProps {
 }
 
 interface IState {
+    useMarkdown: boolean;
     showPillAvatar: boolean;
     query?: string;
     showVisualBell?: boolean;
@@ -124,6 +125,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
     private lastCaret: DocumentOffset;
     private lastSelection: ReturnType<typeof cloneSelection>;
 
+    private readonly useMarkdownHandle: string;
     private readonly emoticonSettingHandle: string;
     private readonly shouldShowPillAvatarSettingHandle: string;
     private readonly surroundWithHandle: string;
@@ -133,10 +135,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
         super(props);
         this.state = {
             showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
+            useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
             surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
             showVisualBell: false,
         };
 
+        this.useMarkdownHandle = SettingsStore.watchSetting('MessageComposerInput.useMarkdown', null,
+            this.configureUseMarkdown);
         this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
             this.configureEmoticonAutoReplace);
         this.configureEmoticonAutoReplace();
@@ -442,7 +447,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
             }
         } else if (!selection.isCollapsed && !isEmpty) {
             this.hasTextSelected = true;
-            if (this.formatBarRef.current) {
+            if (this.formatBarRef.current && this.state.useMarkdown) {
                 const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
                 this.formatBarRef.current.showAt(selectionRect);
             }
@@ -630,6 +635,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
         this.setState({ completionIndex });
     };
 
+    private configureUseMarkdown = (): void => {
+        const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown");
+        this.setState({ useMarkdown });
+        if (!useMarkdown && this.formatBarRef.current) {
+            this.formatBarRef.current.hide();
+        }
+    };
+
     private configureEmoticonAutoReplace = (): void => {
         this.props.model.setTransformCallback(this.transform);
     };
@@ -654,6 +667,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
         this.editorRef.current.removeEventListener("input", this.onInput, true);
         this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
         this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
+        SettingsStore.unwatchSetting(this.useMarkdownHandle);
         SettingsStore.unwatchSetting(this.emoticonSettingHandle);
         SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
         SettingsStore.unwatchSetting(this.surroundWithHandle);
@@ -694,6 +708,10 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
     }
 
     public onFormatAction = (action: Formatting): void => {
+        if (!this.state.useMarkdown) {
+            return;
+        }
+
         const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
 
         this.historyManager.ensureLastChangesPushed(this.props.model);
diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx
index ce6d1b844e..de1bdc9c85 100644
--- a/src/components/views/rooms/EditMessageComposer.tsx
+++ b/src/components/views/rooms/EditMessageComposer.tsx
@@ -95,7 +95,10 @@ function createEditContent(
         body: `${plainPrefix} * ${body}`,
     };
 
-    const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply });
+    const formattedBody = htmlSerializeIfNeeded(model, {
+        forceHTML: isReply,
+        useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
+    });
     if (formattedBody) {
         newContent.format = "org.matrix.custom.html";
         newContent.formatted_body = formattedBody;
@@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
         } else {
             // otherwise, either restore serialized parts from localStorage or parse the body of the event
             const restoredParts = this.restoreStoredEditorState(partCreator);
-            parts = restoredParts || parseEvent(editState.getEvent(), partCreator);
+            parts = restoredParts || parseEvent(editState.getEvent(), partCreator, {
+                shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
+            });
             isRestored = !!restoredParts;
         }
         this.model = new EditorModel(parts, partCreator);
diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx
index b7967aa5ea..c309e0a16c 100644
--- a/src/components/views/rooms/SendMessageComposer.tsx
+++ b/src/components/views/rooms/SendMessageComposer.tsx
@@ -91,7 +91,10 @@ export function createMessageContent(
         msgtype: isEmote ? "m.emote" : "m.text",
         body: body,
     };
-    const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: !!replyToEvent });
+    const formattedBody = htmlSerializeIfNeeded(model, {
+        forceHTML: !!replyToEvent,
+        useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
+    });
     if (formattedBody) {
         content.format = "org.matrix.custom.html";
         content.formatted_body = formattedBody;
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
index a7a54da66e..8b3bdeb7fc 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
@@ -63,6 +63,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
 
     static COMPOSER_SETTINGS = [
         'MessageComposerInput.autoReplaceEmoji',
+        'MessageComposerInput.useMarkdown',
         'MessageComposerInput.suggestEmoji',
         'sendTypingNotifications',
         'MessageComposerInput.ctrlEnterToSend',
diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts
index 7d4f94cdc3..57ef52cafd 100644
--- a/src/editor/deserialize.ts
+++ b/src/editor/deserialize.ts
@@ -52,12 +52,12 @@ function isListChild(n: Node): boolean {
     return LIST_TYPES.includes(n.parentNode?.nodeName);
 }
 
-function parseAtRoomMentions(text: string, pc: PartCreator, shouldEscape = true): Part[] {
+function parseAtRoomMentions(text: string, pc: PartCreator, opts: IParseOptions): Part[] {
     const ATROOM = "@room";
     const parts: Part[] = [];
     text.split(ATROOM).forEach((textPart, i, arr) => {
         if (textPart.length) {
-            parts.push(...pc.plainWithEmoji(shouldEscape ? escape(textPart) : textPart));
+            parts.push(...pc.plainWithEmoji(opts.shouldEscape ? escape(textPart) : textPart));
         }
         // it's safe to never append @room after the last textPart
         // as split will report an empty string at the end if
@@ -70,7 +70,7 @@ function parseAtRoomMentions(text: string, pc: PartCreator, shouldEscape = true)
     return parts;
 }
 
-function parseLink(n: Node, pc: PartCreator): Part[] {
+function parseLink(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
     const { href } = n as HTMLAnchorElement;
     const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID
 
@@ -81,18 +81,18 @@ function parseLink(n: Node, pc: PartCreator): Part[] {
 
     const children = Array.from(n.childNodes);
     if (href === n.textContent && children.every(c => c.nodeType === Node.TEXT_NODE)) {
-        return parseAtRoomMentions(n.textContent, pc);
+        return parseAtRoomMentions(n.textContent, pc, opts);
     } else {
-        return [pc.plain("["), ...parseChildren(n, pc), pc.plain(`](${href})`)];
+        return [pc.plain("["), ...parseChildren(n, pc, opts), pc.plain(`](${href})`)];
     }
 }
 
-function parseImage(n: Node, pc: PartCreator): Part[] {
+function parseImage(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
     const { alt, src } = n as HTMLImageElement;
     return pc.plainWithEmoji(`![${escape(alt)}](${src})`);
 }
 
-function parseCodeBlock(n: Node, pc: PartCreator): Part[] {
+function parseCodeBlock(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
     let language = "";
     if (n.firstChild?.nodeName === "CODE") {
         for (const className of (n.firstChild as HTMLElement).classList) {
@@ -117,10 +117,10 @@ function parseCodeBlock(n: Node, pc: PartCreator): Part[] {
     return parts;
 }
 
-function parseHeader(n: Node, pc: PartCreator): Part[] {
+function parseHeader(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
     const depth = parseInt(n.nodeName.slice(1), 10);
     const prefix = pc.plain("#".repeat(depth) + " ");
-    return [prefix, ...parseChildren(n, pc)];
+    return [prefix, ...parseChildren(n, pc, opts)];
 }
 
 function checkIgnored(n) {
@@ -144,10 +144,10 @@ function prefixLines(parts: Part[], prefix: string, pc: PartCreator) {
     }
 }
 
-function parseChildren(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): Part[] {
+function parseChildren(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] {
     let prev;
     return Array.from(n.childNodes).flatMap(c => {
-        const parsed = parseNode(c, pc, mkListItem);
+        const parsed = parseNode(c, pc, opts, mkListItem);
         if (parsed.length && prev && (checkBlockNode(prev) || checkBlockNode(c))) {
             if (isListChild(c)) {
                 // Use tighter spacing within lists
@@ -161,12 +161,12 @@ function parseChildren(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part
     });
 }
 
-function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): Part[] {
+function parseNode(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] {
     if (checkIgnored(n)) return [];
 
     switch (n.nodeType) {
         case Node.TEXT_NODE:
-            return parseAtRoomMentions(n.nodeValue, pc);
+            return parseAtRoomMentions(n.nodeValue, pc, opts);
         case Node.ELEMENT_NODE:
             switch (n.nodeName) {
                 case "H1":
@@ -175,43 +175,43 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]):
                 case "H4":
                 case "H5":
                 case "H6":
-                    return parseHeader(n, pc);
+                    return parseHeader(n, pc, opts);
                 case "A":
-                    return parseLink(n, pc);
+                    return parseLink(n, pc, opts);
                 case "IMG":
-                    return parseImage(n, pc);
+                    return parseImage(n, pc, opts);
                 case "BR":
                     return [pc.newline()];
                 case "HR":
                     return [pc.plain("---")];
                 case "EM":
-                    return [pc.plain("_"), ...parseChildren(n, pc), pc.plain("_")];
+                    return [pc.plain("_"), ...parseChildren(n, pc, opts), pc.plain("_")];
                 case "STRONG":
-                    return [pc.plain("**"), ...parseChildren(n, pc), pc.plain("**")];
+                    return [pc.plain("**"), ...parseChildren(n, pc, opts), pc.plain("**")];
                 case "DEL":
-                    return [pc.plain("<del>"), ...parseChildren(n, pc), pc.plain("</del>")];
+                    return [pc.plain("<del>"), ...parseChildren(n, pc, opts), pc.plain("</del>")];
                 case "SUB":
-                    return [pc.plain("<sub>"), ...parseChildren(n, pc), pc.plain("</sub>")];
+                    return [pc.plain("<sub>"), ...parseChildren(n, pc, opts), pc.plain("</sub>")];
                 case "SUP":
-                    return [pc.plain("<sup>"), ...parseChildren(n, pc), pc.plain("</sup>")];
+                    return [pc.plain("<sup>"), ...parseChildren(n, pc, opts), pc.plain("</sup>")];
                 case "U":
-                    return [pc.plain("<u>"), ...parseChildren(n, pc), pc.plain("</u>")];
+                    return [pc.plain("<u>"), ...parseChildren(n, pc, opts), pc.plain("</u>")];
                 case "PRE":
-                    return parseCodeBlock(n, pc);
+                    return parseCodeBlock(n, pc, opts);
                 case "CODE": {
                     // Escape backticks by using multiple backticks for the fence if necessary
                     const fence = "`".repeat(longestBacktickSequence(n.textContent) + 1);
                     return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`);
                 }
                 case "BLOCKQUOTE": {
-                    const parts = parseChildren(n, pc);
+                    const parts = parseChildren(n, pc, opts);
                     prefixLines(parts, "> ", pc);
                     return parts;
                 }
                 case "LI":
-                    return mkListItem?.(n) ?? parseChildren(n, pc);
+                    return mkListItem?.(n) ?? parseChildren(n, pc, opts);
                 case "UL": {
-                    const parts = parseChildren(n, pc, li => [pc.plain("- "), ...parseChildren(li, pc)]);
+                    const parts = parseChildren(n, pc, opts, li => [pc.plain("- "), ...parseChildren(li, pc, opts)]);
                     if (isListChild(n)) {
                         prefixLines(parts, "    ", pc);
                     }
@@ -219,8 +219,8 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]):
                 }
                 case "OL": {
                     let counter = (n as HTMLOListElement).start ?? 1;
-                    const parts = parseChildren(n, pc, li => {
-                        const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc)];
+                    const parts = parseChildren(n, pc, opts, li => {
+                        const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc, opts)];
                         counter++;
                         return parts;
                     });
@@ -247,15 +247,20 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]):
             }
     }
 
-    return parseChildren(n, pc);
+    return parseChildren(n, pc, opts);
 }
 
-function parseHtmlMessage(html: string, pc: PartCreator, isQuotedMessage: boolean): Part[] {
+interface IParseOptions {
+    isQuotedMessage?: boolean;
+    shouldEscape?: boolean;
+}
+
+function parseHtmlMessage(html: string, pc: PartCreator, opts: IParseOptions): Part[] {
     // no nodes from parsing here should be inserted in the document,
     // as scripts in event handlers, etc would be executed then.
     // we're only taking text, so that is fine
-    const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc);
-    if (isQuotedMessage) {
+    const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc, opts);
+    if (opts.isQuotedMessage) {
         prefixLines(parts, "> ", pc);
     }
     return parts;
@@ -264,14 +269,14 @@ function parseHtmlMessage(html: string, pc: PartCreator, isQuotedMessage: boolea
 export function parsePlainTextMessage(
     body: string,
     pc: PartCreator,
-    opts: { isQuotedMessage?: boolean, shouldEscape?: boolean },
+    opts: IParseOptions,
 ): Part[] {
     const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n
     return lines.reduce((parts, line, i) => {
         if (opts.isQuotedMessage) {
             parts.push(pc.plain("> "));
         }
-        parts.push(...parseAtRoomMentions(line, pc, opts.shouldEscape));
+        parts.push(...parseAtRoomMentions(line, pc, opts));
         const isLast = i === lines.length - 1;
         if (!isLast) {
             parts.push(pc.newline());
@@ -280,19 +285,19 @@ export function parsePlainTextMessage(
     }, [] as Part[]);
 }
 
-export function parseEvent(event: MatrixEvent, pc: PartCreator, { isQuotedMessage = false } = {}) {
+export function parseEvent(event: MatrixEvent, pc: PartCreator, opts: IParseOptions = { shouldEscape: true }) {
     const content = event.getContent();
     let parts: Part[];
     const isEmote = content.msgtype === "m.emote";
     let isRainbow = false;
 
     if (content.format === "org.matrix.custom.html") {
-        parts = parseHtmlMessage(content.formatted_body || "", pc, isQuotedMessage);
+        parts = parseHtmlMessage(content.formatted_body || "", pc, opts);
         if (content.body && content.formatted_body && textToHtmlRainbow(content.body) === content.formatted_body) {
             isRainbow = true;
         }
     } else {
-        parts = parsePlainTextMessage(content.body || "", pc, { isQuotedMessage });
+        parts = parsePlainTextMessage(content.body || "", pc, opts);
     }
 
     if (isEmote && isRainbow) {
diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts
index 8e0d3d66db..7c4d62e9ab 100644
--- a/src/editor/serialize.ts
+++ b/src/editor/serialize.ts
@@ -17,6 +17,7 @@ limitations under the License.
 
 import { AllHtmlEntities } from 'html-entities';
 import cheerio from 'cheerio';
+import escapeHtml from "escape-html";
 
 import Markdown from '../Markdown';
 import { makeGenericPermalink } from "../utils/permalinks/Permalinks";
@@ -48,7 +49,19 @@ export function mdSerialize(model: EditorModel): string {
     }, "");
 }
 
-export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}): string {
+interface ISerializeOpts {
+    forceHTML?: boolean;
+    useMarkdown?: boolean;
+}
+
+export function htmlSerializeIfNeeded(
+    model: EditorModel,
+    { forceHTML = false, useMarkdown = true }: ISerializeOpts = {},
+): string {
+    if (!useMarkdown) {
+        return escapeHtml(textSerialize(model)).replace(/\n/g, '<br/>');
+    }
+
     let md = mdSerialize(model);
     // copy of raw input to remove unwanted math later
     const orig = md;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 46632c3449..54fc8a8c8f 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -932,6 +932,8 @@
     "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message",
     "Surround selected text when typing special characters": "Surround selected text when typing special characters",
     "Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
+    "Enable Markdown": "Enable Markdown",
+    "Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.": "Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.",
     "Mirror local video feed": "Mirror local video feed",
     "Match system theme": "Match system theme",
     "Use a system font": "Use a system font",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index fa773080be..d9bd0817a4 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -133,7 +133,7 @@ export interface IBaseSetting<T extends SettingValueType = SettingValueType> {
     };
 
     // Optional description which will be shown as microCopy under SettingsFlags
-    description?: string;
+    description?: string | (() => ReactNode);
 
     // The supported levels are required. Preferably, use the preset arrays
     // at the top of this file to define this rather than a custom array.
@@ -611,6 +611,16 @@ export const SETTINGS: {[setting: string]: ISetting} = {
         displayName: _td('Automatically replace plain text Emoji'),
         default: false,
     },
+    "MessageComposerInput.useMarkdown": {
+        supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+        displayName: _td('Enable Markdown'),
+        description: () => _t(
+            "Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.",
+            {},
+            { code: (sub) => <code>{ sub }</code> },
+        ),
+        default: true,
+    },
     "VideoView.flipVideoHorizontally": {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
         displayName: _td('Mirror local video feed'),
diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts
index 95ea0e6993..19a419afb7 100644
--- a/src/settings/SettingsStore.ts
+++ b/src/settings/SettingsStore.ts
@@ -16,6 +16,7 @@ limitations under the License.
 */
 
 import { logger } from "matrix-js-sdk/src/logger";
+import { ReactNode } from "react";
 
 import DeviceSettingsHandler from "./handlers/DeviceSettingsHandler";
 import RoomDeviceSettingsHandler from "./handlers/RoomDeviceSettingsHandler";
@@ -257,9 +258,11 @@ export default class SettingsStore {
      * @param {string} settingName The setting to look up.
      * @return {String} The description for the setting, or null if not found.
      */
-    public static getDescription(settingName: string) {
-        if (!SETTINGS[settingName]?.description) return null;
-        return _t(SETTINGS[settingName].description);
+    public static getDescription(settingName: string): string | ReactNode {
+        const description = SETTINGS[settingName]?.description;
+        if (!description) return null;
+        if (typeof description !== 'string') return description();
+        return _t(description);
     }
 
     /**
diff --git a/test/editor/deserialize-test.ts b/test/editor/deserialize-test.ts
index 86594f78df..47ab6cb2f2 100644
--- a/test/editor/deserialize-test.ts
+++ b/test/editor/deserialize-test.ts
@@ -331,4 +331,78 @@ describe('editor/deserialize', function() {
             expect(parts).toMatchSnapshot();
         });
     });
+    describe('plaintext messages', function() {
+        it('turns html tags back into markdown', function() {
+            const html = "<strong>bold</strong> and <em>emphasized</em> text <a href=\"http://example.com/\">this</a>!";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "**bold** and _emphasized_ text [this](http://example.com/)!",
+            });
+        });
+        it('keeps backticks unescaped', () => {
+            const html = "this → ` is a backtick and here are 3 of them:\n```";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "this → ` is a backtick and here are 3 of them:\n```",
+            });
+        });
+        it('keeps backticks outside of code blocks', () => {
+            const html = "some `backticks`";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "some `backticks`",
+            });
+        });
+        it('keeps backslashes', () => {
+            const html = "C:\\My Documents";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "C:\\My Documents",
+            });
+        });
+        it('keeps asterisks', () => {
+            const html = "*hello*";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "*hello*",
+            });
+        });
+        it('keeps underscores', () => {
+            const html = "__emphasis__";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "__emphasis__",
+            });
+        });
+        it('keeps square brackets', () => {
+            const html = "[not an actual link](https://example.org)";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "[not an actual link](https://example.org)",
+            });
+        });
+        it('escapes angle brackets', () => {
+            const html = "> &lt;del&gt;no formatting here&lt;/del&gt;";
+            const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
+            expect(parts.length).toBe(1);
+            expect(parts[0]).toStrictEqual({
+                type: "plain",
+                text: "> <del>no formatting here</del>",
+            });
+        });
+    });
 });
diff --git a/test/editor/serialize-test.ts b/test/editor/serialize-test.ts
index 40f95e0377..d948285901 100644
--- a/test/editor/serialize-test.ts
+++ b/test/editor/serialize-test.ts
@@ -19,58 +19,80 @@ import { htmlSerializeIfNeeded } from "../../src/editor/serialize";
 import { createPartCreator } from "./mock";
 
 describe('editor/serialize', function() {
-    it('user pill turns message into html', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<a href=\"https://matrix.to/#/@alice:hs.tld\">Alice</a>");
+    describe('with markdown', function() {
+        it('user pill turns message into html', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<a href=\"https://matrix.to/#/@alice:hs.tld\">Alice</a>");
+        });
+        it('room pill turns message into html', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>");
+        });
+        it('@room pill turns message into html', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.atRoomPill("@room")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBeFalsy();
+        });
+        it('any markdown turns message into html', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain("*hello* world")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<em>hello</em> world");
+        });
+        it('displaynames ending in a backslash work', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname\\</a>");
+        });
+        it('displaynames containing an opening square bracket work', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname[[</a>");
+        });
+        it('displaynames containing a closing square bracket work', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname]</a>");
+        });
+        it('escaped markdown should not retain backslashes', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain('\\*hello\\* world')], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe('*hello* world');
+        });
+        it('escaped markdown should convert HTML entities', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc);
+            const html = htmlSerializeIfNeeded(model, {});
+            expect(html).toBe('*hello* world &lt; hey world!');
+        });
     });
-    it('room pill turns message into html', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<a href=\"https://matrix.to/#/#room:hs.tld\">#room:hs.tld</a>");
-    });
-    it('@room pill turns message into html', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.atRoomPill("@room")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBeFalsy();
-    });
-    it('any markdown turns message into html', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.plain("*hello* world")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<em>hello</em> world");
-    });
-    it('displaynames ending in a backslash work', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname\\</a>");
-    });
-    it('displaynames containing an opening square bracket work', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname[[</a>");
-    });
-    it('displaynames containing a closing square bracket work', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe("<a href=\"https://matrix.to/#/@user:server\">Displayname]</a>");
-    });
-    it('escaped markdown should not retain backslashes', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.plain('\\*hello\\* world')], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe('*hello* world');
-    });
-    it('escaped markdown should convert HTML entities', function() {
-        const pc = createPartCreator();
-        const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc);
-        const html = htmlSerializeIfNeeded(model, {});
-        expect(html).toBe('*hello* world &lt; hey world!');
+    describe('with plaintext', function() {
+        it('markdown remains plaintext', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain("*hello* world")], pc);
+            const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
+            expect(html).toBe("*hello* world");
+        });
+        it('markdown should retain backslashes', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain('\\*hello\\* world')], pc);
+            const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
+            expect(html).toBe('\\*hello\\* world');
+        });
+        it('markdown should convert HTML entities', function() {
+            const pc = createPartCreator();
+            const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc);
+            const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
+            expect(html).toBe('\\*hello\\* world &lt; hey world!');
+        });
     });
 });