diff --git a/NAMESPACE b/NAMESPACE
index 5a008bc6..b09712f9 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -2,6 +2,7 @@
 
 export("%>%")
 export(accumulate_pred_trans)
+export(add_attachment_mc)
 export(assert_metacheckable)
 export(auth_cr)
 export(auth_mailjet)
@@ -11,6 +12,8 @@ export(cr_compliance_overview)
 export(cr_funder_df)
 export(cr_has_orcid)
 export(cr_tdm_df)
+export(create_and_attach_ss)
+export(create_ss)
 export(doi_examples)
 export(draft_report)
 export(emailReport)
@@ -38,7 +41,6 @@ export(mc_long_docs)
 export(mc_long_docs_string)
 export(mc_render_email)
 export(mc_translator)
-export(md_data_attachment)
 export(metrics_overview)
 export(pretests)
 export(render_report)
@@ -48,6 +50,7 @@ export(smtp_send_mc)
 export(tabulate_metacheckable)
 export(tdm_metrics)
 export(vor_issue)
+export(write_xlsx_mc)
 import(dplyr)
 import(purrr)
 import(tidyr)
diff --git a/R/email.R b/R/email.R
index 8c01b7f3..07b9b429 100644
--- a/R/email.R
+++ b/R/email.R
@@ -12,10 +12,7 @@ mc_compose_email <- function(dois = doi_examples$good[1:10],
                              ...) {
   mc_body_block(dois = dois, translator = translator, ...) %>%
     mc_compose_email_outer(translator = translator) %>%
-    blastula::add_attachment(
-      md_data_attachment(dois = dois),
-      filename = translator$translate("mc_individual_results.xlsx")
-    )
+    create_and_attach_ss(email = ., dois = dois, translator = translator)
 }
 
 mc_body_block <- function(dois, translator = mc_translator(), ...) {
@@ -431,43 +428,3 @@ email_async <- function(to, translator = mc_translator(), ...) {
   # both are needed upstream
   list(done = promise_done, id_notifi = id_notifi_done)
 }
-
-# excel attachment ====
-
-#' Make Spreadsheet attachment
-#' Creates an excel spreadsheet with individual-level results.
-#' 
-#' @details `r metacheck::mc_long_docs_string("spreadsheet.md")`
-#' 
-#' @param dois character, *all* submitted dois
-#' @param df compliance data from [cr_compliance_overview()]
-#' @inheritParams writexl::write_xlsx
-#' 
-#' @return path to the created file
-#'
-#' @export
-#' @family communicate
-md_data_attachment <- function(dois,
-                               df = cr_compliance_overview(get_cr_md(
-                                 dois[is_metacheckable(dois)]
-                              )),
-                              path = fs::file_temp(ext = "xlsx")) {
-  is_compliance_overview_list(df)
-  df[["pretest"]] <- tibble::tibble(
-    # writexl does not know vctrs records
-    doi = as.character(biblids::as_doi(dois)),
-    tabulate_metacheckable(dois)
-  )
-  writexl::write_xlsx(
-    x = df,
-    path = path
-  )
-}
-
-#' Data is available
-#' @noRd
-is_compliance_overview_list <- function(x) {
-  assertthat::assert_that(x %has_name% c("cr_overview", "cc_license_check"),
-                          msg = "No Compliance Data to attach, compliance data from [cr_compliance_overview()]"
-  )
-}
diff --git a/R/spreadsheet.R b/R/spreadsheet.R
new file mode 100644
index 00000000..dfa68af0
--- /dev/null
+++ b/R/spreadsheet.R
@@ -0,0 +1,52 @@
+#' Store individual results in a spreadsheet
+#' @details `r metacheck::mc_long_docs_string("spreadsheet.md")`
+#' @family communicate
+#' @name spreadsheet
+NULL
+
+#' @describeIn spreadsheet Create individual results
+#' @return A list of tibbles
+#' @inheritParams report
+#' @export
+create_ss <- function(dois = doi_examples$good[1:10]) {
+  df <- cr_compliance_overview(get_cr_md(dois[is_metacheckable(dois)]))
+  df[["pretest"]] <- tibble::tibble(
+    # writexl does not know vctrs records
+    doi = as.character(biblids::as_doi(dois)),
+    tabulate_metacheckable(dois)
+  )
+  df
+}
+
+#' @describeIn spreadsheet Write out file
+#' @inheritParams writexl::write_xlsx
+#' @inheritDotParams writexl::write_xlsx
+#' @export
+write_xlsx_mc <- function(x, path = fs::file_temp(ext = "xlsx"), ...) {
+  writexl::write_xlsx(
+    x = x,
+    path = path,
+    ...
+  )
+}
+
+#' @describeIn spreadsheet Attach file to email
+#' @inheritParams blastula::add_attachment
+#' @export
+add_attachment_mc <- function(email = blastula::prepare_test_message(), 
+                              file,
+                              translator = mc_translator()) {
+  blastula::add_attachment(
+    email = email,
+    file = file,
+    filename = translator$translate("mc_individual_results.xlsx")
+  )
+}
+
+#' @describeIn spreadsheet Create and attach individual results to email
+#' @export
+create_and_attach_ss <- function(dois = doi_examples$good[1:10], ...) {
+  ellipsis::check_dots_used()
+  df <- create_ss(dois = dois)
+  add_attachment_mc(file = write_xlsx_mc(df), ...)
+}
diff --git a/man/email.Rd b/man/email.Rd
index f1c5ac2a..5171f81a 100644
--- a/man/email.Rd
+++ b/man/email.Rd
@@ -143,8 +143,8 @@ Other communicate:
 \code{\link{emailReport}()},
 \code{\link{mcApp}()},
 \code{\link{mcControls}},
-\code{\link{md_data_attachment}()},
 \code{\link{report}},
-\code{\link{runMetacheck}()}
+\code{\link{runMetacheck}()},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
diff --git a/man/emailReport.Rd b/man/emailReport.Rd
index d3f7f448..a9a9852d 100644
--- a/man/emailReport.Rd
+++ b/man/emailReport.Rd
@@ -85,9 +85,9 @@ Other communicate:
 \code{\link{email}},
 \code{\link{mcApp}()},
 \code{\link{mcControls}},
-\code{\link{md_data_attachment}()},
 \code{\link{report}},
-\code{\link{runMetacheck}()}
+\code{\link{runMetacheck}()},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
 \keyword{internal}
diff --git a/man/mcApp.Rd b/man/mcApp.Rd
index 4f3c9bdb..79d320fc 100644
--- a/man/mcApp.Rd
+++ b/man/mcApp.Rd
@@ -14,8 +14,8 @@ Other communicate:
 \code{\link{emailReport}()},
 \code{\link{email}},
 \code{\link{mcControls}},
-\code{\link{md_data_attachment}()},
 \code{\link{report}},
-\code{\link{runMetacheck}()}
+\code{\link{runMetacheck}()},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
diff --git a/man/mcControls.Rd b/man/mcControls.Rd
index fce000a3..8bd6a41c 100644
--- a/man/mcControls.Rd
+++ b/man/mcControls.Rd
@@ -43,8 +43,8 @@ Other communicate:
 \code{\link{emailReport}()},
 \code{\link{email}},
 \code{\link{mcApp}()},
-\code{\link{md_data_attachment}()},
 \code{\link{report}},
-\code{\link{runMetacheck}()}
+\code{\link{runMetacheck}()},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
diff --git a/man/md_data_attachment.Rd b/man/md_data_attachment.Rd
deleted file mode 100644
index 22524706..00000000
--- a/man/md_data_attachment.Rd
+++ /dev/null
@@ -1,47 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/email.R
-\name{md_data_attachment}
-\alias{md_data_attachment}
-\title{Make Spreadsheet attachment
-Creates an excel spreadsheet with individual-level results.}
-\usage{
-md_data_attachment(
-  dois,
-  df = cr_compliance_overview(get_cr_md(dois[is_metacheckable(dois)])),
-  path = fs::file_temp(ext = "xlsx")
-)
-}
-\arguments{
-\item{dois}{character, \emph{all} submitted dois}
-
-\item{df}{compliance data from \code{\link[=cr_compliance_overview]{cr_compliance_overview()}}}
-
-\item{path}{a file name to write to}
-}
-\value{
-path to the created file
-}
-\description{
-Make Spreadsheet attachment
-Creates an excel spreadsheet with individual-level results.
-}
-\details{
-The spreadsheet includes these sheets:
-\itemize{
-\item \code{cr_overview}: Overview results
-\item \code{cc_license_check}: Detailed results licensing check
-\item \code{tdm}: Detailed results full text links and TDM
-\item \code{funder_info}: Funding information from Crossref
-\item \code{pretest}: Results of the pretest
-}
-}
-\seealso{
-Other communicate: 
-\code{\link{emailReport}()},
-\code{\link{email}},
-\code{\link{mcApp}()},
-\code{\link{mcControls}},
-\code{\link{report}},
-\code{\link{runMetacheck}()}
-}
-\concept{communicate}
diff --git a/man/report.Rd b/man/report.Rd
index ee04b238..ada7ceca 100644
--- a/man/report.Rd
+++ b/man/report.Rd
@@ -130,7 +130,7 @@ Other communicate:
 \code{\link{email}},
 \code{\link{mcApp}()},
 \code{\link{mcControls}},
-\code{\link{md_data_attachment}()},
-\code{\link{runMetacheck}()}
+\code{\link{runMetacheck}()},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
diff --git a/man/runMetacheck.Rd b/man/runMetacheck.Rd
index ebd522c1..cd3765d3 100644
--- a/man/runMetacheck.Rd
+++ b/man/runMetacheck.Rd
@@ -57,7 +57,7 @@ Other communicate:
 \code{\link{email}},
 \code{\link{mcApp}()},
 \code{\link{mcControls}},
-\code{\link{md_data_attachment}()},
-\code{\link{report}}
+\code{\link{report}},
+\code{\link{spreadsheet}}
 }
 \concept{communicate}
diff --git a/man/spreadsheet.Rd b/man/spreadsheet.Rd
new file mode 100644
index 00000000..5d0d0a3b
--- /dev/null
+++ b/man/spreadsheet.Rd
@@ -0,0 +1,91 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/spreadsheet.R
+\name{spreadsheet}
+\alias{spreadsheet}
+\alias{create_ss}
+\alias{write_xlsx_mc}
+\alias{add_attachment_mc}
+\alias{create_and_attach_ss}
+\title{Store individual results in a spreadsheet}
+\usage{
+create_ss(dois = doi_examples$good[1:10])
+
+write_xlsx_mc(x, path = fs::file_temp(ext = "xlsx"), ...)
+
+add_attachment_mc(
+  email = blastula::prepare_test_message(),
+  file,
+  translator = mc_translator()
+)
+
+create_and_attach_ss(dois = doi_examples$good[1:10], ...)
+}
+\arguments{
+\item{dois}{Vector of DOIs, as created by, or coerceable to \code{\link[biblids:doi]{biblids::doi()}}.}
+
+\item{x}{data frame or named list of data frames that will be sheets in the xlsx}
+
+\item{path}{a file name to write to}
+
+\item{...}{
+  Arguments passed on to \code{\link[writexl:write_xlsx]{writexl::write_xlsx}}
+  \describe{
+    \item{\code{col_names}}{write column names at the top of the file?}
+    \item{\code{format_headers}}{make the \code{col_names} in the xlsx centered and bold}
+    \item{\code{use_zip64}}{use \href{https://en.wikipedia.org/wiki/Zip_(file_format)#ZIP64}{zip64}
+to enable support for 4GB+ xlsx files. Not all platforms can read this.}
+  }}
+
+\item{email}{The email message object, as created by the \code{\link[blastula:compose_email]{compose_email()}}
+function. The object's class is \code{email_message}.}
+
+\item{file}{The filename for the file to be attached.}
+
+\item{translator}{A \link[shiny.i18n:Translator]{shiny.i18n::Translator} object or \code{NULL} for english-only defaults.
+Strings inside the module UI are marked as translateable.
+You can pass a translator object included in the package,
+or can create your own \code{translator} using \link[shiny.i18n:Translator]{shiny.i18n::Translator}.
+This must not be a reactive, it is only set at shiny startup.
+To update the language reactively \emph{during} a shiny session, see \code{lang}.}
+}
+\value{
+A list of tibbles
+}
+\description{
+Store individual results in a spreadsheet
+}
+\details{
+The spreadsheet includes these sheets:
+\itemize{
+\item \code{cr_overview}: Overview results
+\item \code{cc_license_check}: Detailed results licensing check
+\item \code{tdm}: Detailed results full text links and TDM
+\item \code{funder_info}: Funding information from Crossref
+\item \code{pretest}: Results of the pretest
+}
+}
+\section{Related Functions and Methods}{
+\subsection{Functions}{
+\itemize{
+\item \code{create_ss}: Create individual results
+}
+\itemize{
+\item \code{write_xlsx_mc}: Write out file
+}
+\itemize{
+\item \code{add_attachment_mc}: Attach file to email
+}
+\itemize{
+\item \code{create_and_attach_ss}: Create and attach individual results to email
+}}}
+
+\seealso{
+Other communicate: 
+\code{\link{compose_attach_send_email_promise}()},
+\code{\link{email}},
+\code{\link{mcApp}()},
+\code{\link{mcControls}},
+\code{\link{report}},
+\code{\link{runMetacheck}()}
+}
+\concept{communicate}
diff --git a/tests/testthat/_snaps/spreadsheet.md b/tests/testthat/_snaps/spreadsheet.md
new file mode 100644
index 00000000..e1439a7c
--- /dev/null
+++ b/tests/testthat/_snaps/spreadsheet.md
@@ -0,0 +1,8 @@
+# individual results are assembled
+
+    Code
+      names(create_ss())
+    Output
+      [1] "cr_overview"      "cc_license_check" "tdm"              "funder_info"     
+      [5] "pretest"         
+
diff --git a/tests/testthat/test-spreadsheet.R b/tests/testthat/test-spreadsheet.R
new file mode 100644
index 00000000..c84fe678
--- /dev/null
+++ b/tests/testthat/test-spreadsheet.R
@@ -0,0 +1,11 @@
+test_that("individual results are assembled", {
+  expect_snapshot(names(create_ss()))
+})
+
+test_that("spreadsheet is written to file", {
+  checkmate::expect_file_exists(write_xlsx_mc(create_ss()))
+})
+
+test_that("individual results can be created and attached", {
+  expect_true(length(create_and_attach_ss()$attachments) == 1L)
+})