diff --git a/services/reports/Cargo.lock b/services/reports/Cargo.lock
--- a/services/reports/Cargo.lock
+++ b/services/reports/Cargo.lock
@@ -369,6 +369,17 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
 
+[[package]]
+name = "async-trait"
+version = "0.1.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.1.0"
@@ -1791,6 +1802,23 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
 
+[[package]]
+name = "postmark"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "254a5dd703e0cb58b305d882618698682719141a09483868401ba3d0e689a96b"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "http",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "typed-builder",
+ "url",
+]
+
 [[package]]
 name = "ppv-lite86"
 version = "0.2.17"
@@ -1940,6 +1968,7 @@
  "num-derive",
  "num-traits",
  "once_cell",
+ "postmark",
  "serde",
  "serde_json",
  "serde_repr",
@@ -2320,6 +2349,26 @@
  "windows-sys",
 ]
 
+[[package]]
+name = "thiserror"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
 [[package]]
 name = "thread_local"
 version = "1.1.7"
@@ -2569,6 +2618,17 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
 
+[[package]]
+name = "typed-builder"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cba322cb9b7bc6ca048de49e83918223f35e7a86311267013afff257004870"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "typenum"
 version = "1.16.0"
diff --git a/services/reports/Cargo.toml b/services/reports/Cargo.toml
--- a/services/reports/Cargo.toml
+++ b/services/reports/Cargo.toml
@@ -24,6 +24,7 @@
 num-traits = "0.2"
 num-derive = "0.4"
 once_cell = "1.17"
+postmark = { version = "0.8", features = ["reqwest"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 serde_repr = "0.1"
diff --git a/services/reports/src/email/config.rs b/services/reports/src/email/config.rs
--- a/services/reports/src/email/config.rs
+++ b/services/reports/src/email/config.rs
@@ -41,6 +41,18 @@
   pub mailing_groups: HashMap<MailingGroup, String>,
 }
 
+impl EmailConfig {
+  pub fn recipient_for_report_type(
+    &self,
+    report_type: &ReportType,
+  ) -> Option<&str> {
+    self
+      .mailing_groups
+      .get(&report_type.into())
+      .map(String::as_str)
+  }
+}
+
 impl FromStr for EmailConfig {
   type Err = serde_json::Error;
   fn from_str(s: &str) -> Result<Self, Self::Err> {
diff --git a/services/reports/src/email/mod.rs b/services/reports/src/email/mod.rs
--- a/services/reports/src/email/mod.rs
+++ b/services/reports/src/email/mod.rs
@@ -1,2 +1,73 @@
+use postmark::{
+  api::email::{self, SendEmailBatchRequest},
+  reqwest::{PostmarkClient, PostmarkClientError},
+  Query,
+};
+use tracing::{debug, trace, warn};
+
+use crate::{
+  config::CONFIG,
+  report_types::{ReportID, ReportInput, ReportType},
+};
+
 pub mod config;
 mod template;
+
+pub type EmailError = postmark::QueryError<PostmarkClientError>;
+
+pub struct ReportEmail {
+  report_type: ReportType,
+  rendered_message: String,
+  subject: String,
+}
+
+pub fn prepare_email(
+  report_input: &ReportInput,
+  report_id: &ReportID,
+  user_id: Option<&str>,
+) -> ReportEmail {
+  let message =
+    template::render_email_for_report(report_input, report_id, user_id);
+  let subject = template::subject_for_report(report_input, user_id);
+  ReportEmail {
+    report_type: report_input.report_type,
+    rendered_message: message,
+    subject,
+  }
+}
+
+pub async fn send_emails(
+  emails: impl IntoIterator<Item = ReportEmail>,
+) -> Result<(), EmailError> {
+  let Some(email_config) = CONFIG.email_config() else {
+    debug!("E-mail config unavailable. Skipping sending e-mails");
+    return Ok(());
+  };
+
+  // it's cheap to build this every time
+  let client = PostmarkClient::builder()
+    .token(&email_config.postmark_token)
+    .build();
+
+  let requests: SendEmailBatchRequest = emails
+    .into_iter()
+    .filter_map(|item| {
+      let Some(recipient) = email_config.recipient_for_report_type(&item.report_type) else {
+        warn!("Recipient E-mail for {:?} not configured. Skipping", &item.report_type);
+        return None;
+      };
+
+      let email = email::SendEmailRequest::builder()
+        .from(&email_config.sender_email)
+        .to(recipient)
+        .body(email::Body::html(item.rendered_message))
+        .subject(item.subject)
+        .build();
+      Some(email)
+    })
+    .collect();
+
+  let responses = requests.execute(&client).await?;
+  trace!(?responses, "E-mails sent successfully.");
+  Ok(())
+}