Templates11 min read

HIPAA-compliant email for developers: BAAs, reminders, lab results

Which email APIs sign a HIPAA BAA (SES and Mailgun do; SendGrid, Postmark, and Resend don't), what patient emails can say, and how to enforce it in code.

R

React Emails Pro

October 2, 2026

TL;DR

This is for developers who send email for a clinic, a telehealth service, or another HIPAA covered entity, or who build software that does. You'll decide which provider carries the mail, what each message says, and what your code is allowed to put into it. None of it is legal advice. Your BAA and your compliance lead get the final word.

  • SendGrid, Postmark, and Resend say they won't sign a BAA. Amazon SES and Mailgun will.
  • A reminder with a patient's name and appointment time is PHI. Keep it short and leave the details behind the portal login.
  • Mail servers use TLS only when both sides support it. SES can require it for a configuration set.
  • Patients can ask for their details by regular email. Warn them, record the answer, then send.
ProviderSigns a BAA?What its own documentation says
Amazon SESYes, under the AWS BAAListed as a HIPAA Eligible Service (list updated September 3, 2026). The BAA is accepted in AWS Artifact.
MailgunYesPublishes a HIPAA Business Associate Addendum to its terms of service
PauboxYesEmail API built for healthcare; a BAA is included with every plan
Twilio SendGridNoNot a HIPAA Eligible Service; customers should not use it for PHI
PostmarkNoNot HIPAA compliant and cannot sign Business Associate Agreements
ResendNoNot HIPAA compliant and cannot sign a Business Associate Agreement

Taken from each provider's own pages on October 2, 2026. These policies change, so check again before you sign anything.

React Email doesn't know anything about HIPAA. It turns components into HTML, and the HTML goes wherever your send function points. In a lot of Next.js projects that's Resend, the default in most tutorials, including several on this site. If the recipient is a patient, that's the first line to change.

Picking a provider is the quick decision. Most of the work is deciding what each email says, then making sure your code can't send more than that.


Does HIPAA apply to your email?

HIPAA applies to covered entities (health plans, clearinghouses, and health care providers that bill insurance electronically) and to their business associates, the vendors that handle PHI for them. If you sell scheduling or portal software to clinics, you are most likely a business associate, and your email provider is your subcontractor. The chain of BAAs has to reach the company that actually delivers the mail.

PHI covers more than diagnoses. A name, an email address, and the fact that someone is a patient of a particular practice are enough. "Hi Dana, your appointment at Northside Cardiology is Tuesday at 9:30" is PHI. So is the plain fact that Northside Cardiology sends email to dana@example.com. A patient reminder can't be PHI-free. What you control is how little it says.

Not a covered entity? Direct-to-consumer fitness, sleep, and diet apps that don't work on behalf of a provider usually fall outside HIPAA. The FTC's Health Breach Notification Rule covers many of them instead, and its 2024 amendments name health apps explicitly. Under that rule, an unauthorized disclosure, such as sharing health data with an ad platform, can count as a breach. The patterns below are a sensible default for those apps too.


Which email APIs will sign a BAA

It's tempting to treat an email API as a pipe that doesn't need a BAA. HIPAA does have a conduit exception, but it is narrow: it covers services that only transmit data and have transient access to it. HHS's cloud computing guidancesays a provider that stores PHI isn't a conduit, even if it never holds the decryption key. Email APIs queue messages for retries and keep logs and event history, and many store the full message for their dashboards. They are business associates.

A provider without a BAA can still carry email that contains no PHI. In practice that means email to clinic staff (your customers, if you sell to practices), your own subscription billing, and the newsletter on your marketing site. Email from a practice to its patients almost always contains some PHI, so send it through the BAA provider. Trying to scrub it into compliance is a losing game.

Resend runs on Amazon SES, as our Resend and SES articleexplains, so people ask whether AWS's BAA covers it. It doesn't. The AWS BAA covers your AWS account and the eligible services you use in it. For mail sent through Resend, Resend is your business associate, and its security pagesays it can't sign a BAA.

A signed BAA doesn't make your email compliant. Mailgun's addendum is unusually blunt about this. It says content is sent even when the recipient's server doesn't support TLS, "resulting in an unencrypted transmission." It also expects you to confirm the recipient's address before sending PHI, to include a notice about the insecurity of email with a contact for misdirected messages, and to encrypt PHI where the Security Rule requires it. That list is good advice whichever provider you use.

Require TLS on the patient configuration set

Mail servers upgrade to TLS with STARTTLS when both sides support it, and fall back to plain text when they don't. SES behaves the same way by default. Its security protocols pagedescribes the other option: set a configuration set's TLS policy to REQUIRE, and SES drops a message rather than deliver it unencrypted.

setup-ses.sh
# Run in the AWS account where the BAA has been accepted.
aws sesv2 create-configuration-set \
  --configuration-set-name patient-notices \
  --delivery-options TlsPolicy=REQUIRE

# For an existing configuration set:
aws sesv2 put-configuration-set-delivery-options \
  --configuration-set-name patient-notices \
  --tls-policy REQUIRE

aws sesv2 get-configuration-set \
  --configuration-set-name patient-notices \
  --query 'DeliveryOptions.TlsPolicy'

A dropped message is the right failure for PHI, but it's still a failure. Gmail, Outlook.com, and Yahoo all accept TLS, so drops are more likely at small or old self-hosted mail servers. Publish delivery and bounce events for this configuration set, and treat a notice that never gets a delivery event as unsent. The result is still in the portal, and someone at the practice can call.

In January 2025, HHS proposed Security Rule changesthat would require encryption of ePHI in transit, with a few exceptions. One covers patients who ask, under their right of access, to receive their records unencrypted, provided they were told the risks and both facts were documented before sending. The proposal separates those requests from other messages and gives appointment reminders as an example of the other kind. The rule isn't final: the 2026 Unified Agenda lists it as a long-term action, and the projected date for a final rule is July 2027. Requiring TLS now is cheap, and it covers the hop you control however the rule ends up.


What each patient email should say

HHS's FAQ on emailing patientssays the Privacy Rule allows it with reasonable safeguards. Its examples include checking the address before sending and "limiting the amount or type of information disclosed through the unencrypted e-mail." Even with TLS required, you only control the hop out of your provider. After that, the message sits on a phone lock screen or a shared family laptop, and you have no say in it. So keep the default small:

EmailPut in the emailKeep behind the login
Appointment confirmedFirst name, date and time, location address, a link to manage the visitVisit reason, provider specialty, prep instructions tied to a procedure
Appointment reminderThe same, plus how to cancel or rescheduleThe same
Lab result readyThat a new result is available, and a sign-in linkTest name, values, ranges, clinician comments
Prescription readyThat an order is ready, pharmacy name and hoursDrug name, dose, quantity
Provider messageThat the care team sent a new messageThe message itself
Telehealth visitDate and time, a join link that requires sign-inVisit reason, intake answers
Statement readyThat a statement is available, and the amount dueLine items, service descriptions, billing codes
Password reset, email verificationThe usual account contentNothing health related belongs here

A conservative default for patient email. Patients can ask for more, covered further down.

Sender name, subject, and preview text

The parts of an email that people see without opening it are the easiest to overlook in a template. The From name, the subject, and React Email's <Preview>text all show up on lock screens and in the inbox list. "Northside Health" is a better From name than "Northside Fertility Clinic," and "New result in your Northside Health portal" is a better subject than "Your HbA1c result." For behavioral health, substance use treatment, reproductive health, and similar services, the department name alone can say more than the patient wants on a lock screen. Use the parent organization's name.

A link like /results/LAB-88123?test=hba1cputs a record ID and a test name in the patient's mail logs, in the security scanners that fetch links before delivery, and in browser history. Point links at a portal section such as /results and let the portal show the item after sign-in. If you use one-click links that change state, the agent approval email guide covers why scanners make them fire on their own.

What the portal pattern gets you
  • Less PHI in inboxes, backups, and forwarded threads
  • A misdirected email reveals very little
  • The portal can show a corrected result; a sent email stays as sent
  • The portal logs who viewed what
What it costs
  • Patients have to sign in, and some will phone instead
  • Portal password resets turn into support work
  • Caregivers and older patients often lack portal access
  • Some patients will want details by email anyway, and they are entitled to ask

Make the minimal version the only one that compiles

The template is the last place to enforce what goes into an email. By the time props reach it, they have passed through your job queue, your logs, and maybe your error tracker. A component that ignores an extra testNameprop doesn't help much if the job payload sitting in Redis contains it.

TypeScript won't catch this on its own. Excess property checks only apply to object literals. Assign a database row to LabResultReadyProps, spread it into the component, or pass it to a function, and the extra fields type-check fine. We tried all three with a row carrying testName and value, and the compiler accepted each one. Validate at runtime, where data enters the email pipeline, and reject keys you didn't ask for.

lib/patient-email-schemas.ts
import { z } from "zod";

const PORTAL_ORIGIN = "https://portal.northside.example";
const PORTAL_SECTIONS = ["/appointments", "/results", "/messages", "/prescriptions", "/billing"];

// Links point at a portal section, never at a specific record.
const portalLink = z
  .string()
  .url()
  .refine((value) => {
    const url = new URL(value);
    return (
      url.origin === PORTAL_ORIGIN &&
      PORTAL_SECTIONS.includes(url.pathname) &&
      url.search === "" &&
      url.hash === ""
    );
  }, "Use a portal section URL with no identifiers");

const patientBase = {
  firstName: z.string().trim().min(1).max(40),
  organizationName: z.string().min(1),
  supportPhone: z.string().min(7),
};

export const appointmentReminderSchema = z
  .object({
    ...patientBase,
    startsAt: z.string().datetime({ offset: true }),
    timeZone: z.string(),
    locationName: z.string(),
    locationAddress: z.string(),
    manageUrl: portalLink,
  })
  .strict();

export const labResultReadySchema = z
  .object({ ...patientBase, portalUrl: portalLink })
  .strict();

export type AppointmentReminderProps = z.infer<typeof appointmentReminderSchema>;
export type LabResultReadyProps = z.infer<typeof labResultReadySchema>;

export function parsePatientEmailProps<S extends z.ZodTypeAny>(
  template: string,
  schema: S,
  input: unknown,
): z.infer<S> {
  const result = schema.safeParse(input);
  if (result.success) return result.data;

  // Report field names, never values: the values are what must not leak.
  const problems = result.error.issues.map((issue) =>
    issue.code === "unrecognized_keys"
      ? `unexpected ${issue.keys.join(", ")}`
      : issue.path.join("."),
  );
  throw new Error(`${template}: invalid props (${problems.join("; ")})`);
}

.strict() turns an unexpected field into an error instead of quietly dropping it. That is on purpose: a silent strip hides the bug that put a lab value into the pipeline. Passing a row with testName and value throws lab-result-ready: invalid props (unexpected testName, value). The message names the fields and leaves out what was in them, because it will end up in your logs and your error tracker. Alert on it, since a failed parse means a patient didn't get a notice.

The portalLink check accepts the five section URLs and nothing else. A record path, a query string, a fragment, plain HTTP, and another origin all fail.

emails/lab-result-ready.tsx
import * as React from "react";
import {
  Html, Head, Preview, Body, Container, Heading, Text, Button, Hr,
} from "react-email";
import type { LabResultReadyProps } from "@/lib/patient-email-schemas";

const text = { fontSize: "15px", lineHeight: "24px", color: "#334155" };
const small = { fontSize: "13px", lineHeight: "20px", color: "#64748b" };

export default function LabResultReady({
  firstName,
  organizationName,
  supportPhone,
  portalUrl,
}: LabResultReadyProps) {
  return (
    <Html lang="en">
      <Head />
      <Preview>Sign in to your patient portal to view it.</Preview>
      <Body style={{ margin: 0, backgroundColor: "#f1f5f9", fontFamily: "Arial, sans-serif" }}>
        <Container style={{ maxWidth: "560px", padding: "32px 24px", backgroundColor: "#ffffff" }}>
          <Heading as="h1" style={{ fontSize: "22px", lineHeight: "30px", color: "#0f172a" }}>
            A new result is ready
          </Heading>
          <Text style={text}>
            Hi {firstName}, a new result is available in your {organizationName} patient
            portal.
          </Text>
          <Button
            href={portalUrl}
            style={{ backgroundColor: "#0f766e", color: "#ffffff", borderRadius: "6px", padding: "12px 20px", fontSize: "15px" }}
          >
            Sign in to view
          </Button>
          <Text style={text}>
            We don&apos;t put results in email. If you can&apos;t sign in, call us at{" "}
            {supportPhone}.
          </Text>
          <Hr style={{ borderColor: "#e2e8f0" }} />
          <Text style={small}>
            This message went to the email address on file for a {organizationName} patient
            portal account. If you received it by mistake, please call {supportPhone} and
            delete it.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

LabResultReady.PreviewProps = {
  firstName: "Dana",
  organizationName: "Northside Health",
  supportPhone: "(555) 010-0199",
  portalUrl: "https://portal.northside.example/results",
} satisfies LabResultReadyProps;

The props type comes from the schema, so the template can't reference a field the schema doesn't allow. The footer is the misdirected-message notice from Mailgun's list, and it's worth having with any provider. The preview text says nothing about the result, because it shows on the lock screen.

jobs/notify-lab-result.tsx
import LabResultReady from "@/emails/lab-result-ready";
import { labResultReadySchema, parsePatientEmailProps } from "@/lib/patient-email-schemas";
import { sendPatientEmail } from "@/lib/send-patient-email";
import { getPatient, recordNotice } from "@/lib/db";

// The queued job holds one ID. Patient data is loaded at send time.
export async function notifyLabResult(job: { patientId: string }): Promise<void> {
  const patient = await getPatient(job.patientId);

  // Unconfirmed address: the portal and your other channels still show the result.
  if (!patient.email || !patient.emailVerifiedAt) return;

  const props = parsePatientEmailProps("lab-result-ready", labResultReadySchema, {
    firstName: patient.firstName,
    organizationName: "Northside Health",
    supportPhone: "(555) 010-0199",
    portalUrl: "https://portal.northside.example/results",
  });

  const messageId = await sendPatientEmail({
    to: patient.email,
    subject: "New result in your Northside Health portal",
    template: "lab-result-ready",
    patientRef: patient.id,
    element: <LabResultReady {...props} />,
  });

  await recordNotice({ patientId: patient.id, template: "lab-result-ready", messageId });
}

The job payload is a patient ID, so your queue never holds a result. getPatient and recordNotice stand in for your data layer. The emailVerifiedAtcheck is the HHS FAQ's address confirmation, written as code. Send a verification email at sign-up and whenever the address changes. The email verification teardown covers how to make that email work.

lib/send-patient-email.ts
import type { ReactElement } from "react";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { render, toPlainText } from "react-email";

export const ses = new SESv2Client({});

export interface PatientEmail {
  to: string;
  subject: string;
  template: string;
  // Your internal patient ID. Never a name, MRN, or email address.
  patientRef: string;
  element: ReactElement;
}

export async function sendPatientEmail(email: PatientEmail): Promise<string> {
  const html = await render(email.element);

  const { MessageId } = await ses.send(
    new SendEmailCommand({
      FromEmailAddress: "Northside Health <care@notices.northside.example>",
      ReplyToAddresses: ["patient-support@northside.example"],
      Destination: { ToAddresses: [email.to] },
      // Created with TlsPolicy=REQUIRE in setup-ses.sh.
      ConfigurationSetName: "patient-notices",
      // Stays off even if someone enables tracking on the account or configuration set later.
      ConfigurationOverrides: {
        Tracking: { OpenTrackingEnabled: "DISABLED", ClickTrackingEnabled: "DISABLED" },
      },
      Content: {
        Simple: {
          Subject: { Data: email.subject, Charset: "UTF-8" },
          Body: {
            Html: { Data: html, Charset: "UTF-8" },
            Text: { Data: toPlainText(html), Charset: "UTF-8" },
          },
        },
      },
      EmailTags: [{ Name: "template", Value: email.template }],
    }),
  );

  if (!MessageId) throw new Error(`${email.template}: SES returned no message ID`);

  // Enough to match bounce events later. No address, subject, or body.
  console.info(
    JSON.stringify({
      event: "patient_email_sent",
      template: email.template,
      patientRef: email.patientRef,
      messageId: MessageId,
    }),
  );
  return MessageId;
}
  • ConfigurationSetName points at the TLS-required set from the setup step.
  • ConfigurationOverrides.Trackingturns off the open pixel and link rewriting for this message, even if someone enables engagement tracking for the account later. Open tracking records when a patient read a health notice, and click tracking sends every portal link through a redirect domain. You don't need either for a notice like this. The open and click tracking guide is for the email where you do.
  • The plain-text part comes from toPlainText on the same HTML, so the two parts can't drift apart.
  • The log line holds a template name, your internal ID, and the SES message ID. Store the message ID with the notice, as the job does, so bounce events can be matched without ever logging an address.

When a patient asks for details by email

Some patients will want the result in the email itself. HIPAA gives them two relevant rights. They can ask to be contacted by other means, and the HHS FAQ uses email reminders instead of postcards as its example. Under the right of access, they can also ask for copies of their records by unencrypted email. HHS's access guidancesays the provider must give "a brief warning" that the information could be read by a third party in transit, confirm the patient still wants it, and then comply. Once that's done, the provider isn't responsible for a disclosure in transit.

Keep that as data you can show later, not as a checkbox in a template:

lib/email-detail-consent.ts
export interface EmailDetailConsent {
  patientId: string;
  // The patient asked for details by regular email.
  requestedAt: Date;
  // The exact risk notice they saw, so you can show it again in an audit.
  riskNoticeVersion: string;
  riskNoticeConfirmedAt: Date;
  revokedAt: Date | null;
}

export function mayEmailDetails(
  consent: EmailDetailConsent | null,
  now: Date = new Date(),
): consent is EmailDetailConsent {
  return (
    consent !== null &&
    consent.revokedAt === null &&
    consent.requestedAt <= now &&
    consent.riskNoticeConfirmedAt <= now
  );
}

Version the warning text and keep every version, so you can show what a patient agreed to two years from now. The proposed Security Rule changes would require exactly these two records, the request and the warning, before the email goes out. A detailed variant of a template gets its own strict schema, and the job only picks it when mayEmailDetails returns true.


Everywhere else a copy ends up

The message body is only one copy. Most of the tools around the send keep their own.

  • Application logs carry the template name, your internal ID, and the message ID. They never carry rendered HTML, props, subjects, or recipient addresses.
  • Error trackers capture request bodies and breadcrumbs. Scrub them, or that vendor needs a BAA too.
  • Queue payloads hold IDs, and the worker loads data at send time.
  • PreviewProps, test fixtures, and screenshots in pull requests use made-up patients. Nobody copies a production record to reproduce a rendering bug.
  • Replies go somewhere covered by a BAA, and someone reads them. Patients answer reminders with symptoms and questions. The Reply-To guide covers the deliverability side.
  • You know how long your provider keeps message content and events, and who on your team can open them in its dashboard.
  • No compliance BCC to an ordinary mailbox. Each archive copy is one more place PHI lives.

No email template is HIPAA compliant by itself, ours included. A template can keep the content small and carry the right footer. Compliance is about how you send it.

Key takeaway
  • Send patient email through a provider that signs a BAA. Today that rules out SendGrid, Postmark, and Resend.
  • Require TLS on the patient configuration set, and treat a notice with no delivery event as unsent.
  • Default to notification-only content: first name, time, place, sign-in link. Details stay in the portal.
  • Validate props with strict schemas before they reach the queue, and log IDs, never values.
  • Record a patient's request and the risk warning before emailing them details.

Does HIPAA permit health care providers to use email to discuss health issues?

HHS's answer on emailing patients, address checks, limiting content, and patient requests for other means of contact.

hhs.gov

Amazon SES and security protocols

How SES uses opportunistic TLS by default, and how a configuration set can require it.

docs.aws.amazon.com

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates