diff --git a/docs/_static/table-formatting.js b/docs/_static/table-formatting.js
new file mode 100644
index 0000000..c3f225f
--- /dev/null
+++ b/docs/_static/table-formatting.js
@@ -0,0 +1,40 @@
+/**
+ * Return the first sibling of el that matches CSS selector, or null if no matches.
+ * @param {HTMLElement} el
+ * @param {string} selector
+ * @returns {HTMLElement|null}
+ */
+function nextSiblingMatching(el, selector) {
+ while (el && el.nextElementSibling) {
+ el = el.nextElementSibling;
+ if (el.matches(selector)) {
+ return el;
+ }
+ }
+ return null;
+}
+
+/**
+ * Convert runs of empty
elements to a colspan on the first | .
+ */
+function collapseEmptyTableCells() {
+ document.querySelectorAll(".rst-content tr:has(td:empty)").forEach((tr) => {
+ for (
+ let spanStart = tr.querySelector("td");
+ spanStart;
+ spanStart = nextSiblingMatching(spanStart, "td")
+ ) {
+ let emptyCell;
+ while ((emptyCell = nextSiblingMatching(spanStart, "td:empty"))) {
+ emptyCell.remove();
+ spanStart.colSpan++;
+ }
+ }
+ });
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", collapseEmptyTableCells);
+} else {
+ collapseEmptyTableCells();
+}
diff --git a/docs/conf.py b/docs/conf.py
index 820249d..e1d575e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -279,6 +279,7 @@ def setup(app):
anymail_config_js = (DOCS_PATH / "_static/anymail-config.js").read_text()
app.add_js_file(None, body=anymail_config_js)
app.add_js_file("version-alert.js", **{"async": "async"})
+ app.add_js_file("table-formatting.js", **{"async": "async"})
app.add_js_file("https://unpkg.com/rate-the-docs", **{"async": "async"})
# Django-specific roles, from
diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv
new file mode 100644
index 0000000..affd044
--- /dev/null
+++ b/docs/esps/esp-feature-matrix.csv
@@ -0,0 +1,19 @@
+Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`
+.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,
+:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes
+:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes
+:attr:`~AnymailMessage.merge_metadata`,No,No,No,Yes,Yes,Yes,No,Yes,No,Yes,Yes
+:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes
+:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag
+:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
+:attr:`~AnymailMessage.track_opens`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
+:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes
+.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,
+:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
+:attr:`~AnymailMessage.merge_data`,Yes,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes
+:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes
+.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,
+:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
+:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
+.. rubric:: :ref:`Inbound handling `,,,,,,,,,,,
+:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes
diff --git a/docs/esps/index.rst b/docs/esps/index.rst
index 06f8ae9..19a54c6 100644
--- a/docs/esps/index.rst
+++ b/docs/esps/index.rst
@@ -33,56 +33,25 @@ The table below summarizes the Anymail features supported for each ESP.
.. currentmodule:: anymail.message
-.. rst-class:: sticky-left
-
-============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== ===========
-Email Service Provider |Amazon SES| |Brevo| |MailerSend| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |Resend| |SendGrid| |SparkPost|
-============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== ===========
-.. rubric:: :ref:`Anymail send options `
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-:attr:`~AnymailMessage.envelope_sender` Yes No No Domain only Yes Domain only Yes No No No Yes
-:attr:`~AnymailMessage.metadata` Yes Yes No Yes Yes Yes No Yes Yes Yes Yes
-:attr:`~AnymailMessage.merge_metadata` No No No Yes Yes Yes No Yes No Yes Yes
-:attr:`~AnymailMessage.send_at` No Yes Yes Yes No Yes No No No Yes Yes
-:attr:`~AnymailMessage.tags` Yes Yes Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag
-:attr:`~AnymailMessage.track_clicks` No No Yes Yes Yes Yes No Yes No Yes Yes
-:attr:`~AnymailMessage.track_opens` No No Yes Yes Yes Yes No Yes No Yes Yes
-:ref:`amp-email` Yes No No Yes No No No No No Yes Yes
-
-.. rubric:: :ref:`templates-and-merge`
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes No Yes No Yes Yes
-:attr:`~AnymailMessage.merge_data` Yes No Yes Yes Yes Yes No Yes No Yes Yes
-:attr:`~AnymailMessage.merge_global_data` Yes Yes (emulated) (emulated) Yes Yes No Yes No Yes Yes
-.. rubric:: :ref:`Status ` and :ref:`event tracking `
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
-|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
-.. rubric:: :ref:`Inbound handling `
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes No Yes Yes
-============================================ ============ ======= ============ =========== ========== =========== ========== ========== ======== ========== ===========
+.. It's much easier to edit esp-feature-matrix.csv with a CSV-aware editor, such as:
+.. PyCharm (Pro has native CSV support; use a CSV editor plugin with Community)
+.. VSCode with a CSV editor extension
+.. Excel (watch out for charset issues), Apple Numbers, or Google Sheets
+.. Every row must have the same number of columns. If you add a column, you must
+.. also add a comma to each sub-heading row. (A CSV editor should handle this for you.)
+.. Please keep columns sorted alphabetically by ESP name.
+.. csv-table::
+ :file: esp-feature-matrix.csv
+ :header-rows: 1
+ :widths: auto
+ :class: sticky-left
Trying to choose an ESP? Please **don't** start with this table. It's far more
important to consider things like an ESP's deliverability stats, latency, uptime,
and support for developers. The *number* of extra features an ESP offers is almost
meaningless. (And even specific features don't matter if you don't plan to use them.)
-.. |Amazon SES| replace:: :ref:`amazon-ses-backend`
-.. |Brevo| replace:: :ref:`brevo-backend`
-.. |MailerSend| replace:: :ref:`mailersend-backend`
-.. |Mailgun| replace:: :ref:`mailgun-backend`
-.. |Mailjet| replace:: :ref:`mailjet-backend`
-.. |Mandrill| replace:: :ref:`mandrill-backend`
-.. |Postal| replace:: :ref:`postal-backend`
-.. |Postmark| replace:: :ref:`postmark-backend`
-.. |Resend| replace:: :ref:`resend-backend`
-.. |SendGrid| replace:: :ref:`sendgrid-backend`
-.. |SparkPost| replace:: :ref:`sparkpost-backend`
-.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
-.. |AnymailInboundEvent| replace:: :class:`~anymail.signals.AnymailInboundEvent`
-
Other ESPs
----------
diff --git a/pyproject.toml b/pyproject.toml
index 1d75d5e..ee1fb7b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -109,8 +109,6 @@ max-line-length = 88
target-version = ["py37"]
[tool.doc8]
-# ignore very long lines in ESP support table:
-ignore-path-errors = ["docs/esps/index.rst;D001"]
# for now, Anymail allows longer lines in docs source:
max-line-length = 120
|