Skip to content
json-schema email-parsing dates time-zones data-quality api

Ambiguous Dates and Time Zones in Email Extraction

Extract dates from emails without inventing a time zone: keep the source text, separate dates from instants, and route doubtful results to review.

MailFrame Team

“Let’s meet on 04/05 at 10:00” is a perfectly normal sentence in an email. It is also missing a year, a time zone, and any way to tell whether April 5 or May 4 was meant. If your extraction schema has a required date-time field, that email forces a choice: fail validation, or produce a timestamp that looks precise and is partly made up.

This guide covers a workflow for dates in POST /v1/parse results: keep the original wording, model dates and instants as different things, leave the time zone unset when the email never gave one, and send anything doubtful to a person. A note on scope up front. MailFrame today is the direct, synchronous POST /v1/parse call: raw email plus a JSON Schema in, schema-validated JSON back in the same HTTP response. Everything about resolving, defaulting, and reviewing dates below is your application’s policy, written in your code. MailFrame does not resolve time zones or queue anything for review on your behalf.

Three different things called “a date”

Most date bugs start when one field is asked to hold several different kinds of value.

KindExample in an emailWhat it really is
Calendar date”due March 4, 2027”A day on a calendar, no time, no zone
Wall-clock time without a zone”at 10:00”A local time that points at no single instant
Instant”3:00 PM EST (UTC-05:00)“One exact moment, because an offset is stated

A calendar date is not midnight in some zone. If you store “due March 4” as 2027-03-04T00:00:00Z, a user in a zone behind UTC sees March 3 when you render it. That is an error your schema introduced, not one the email contained.

An instant needs an offset or a zone named in the email. “10:00” alone is not an instant, and adding Z to it does not make it one. It only hides the missing information.

Keep the source wording next to the value

Whatever you extract, also extract the phrase it came from. That gives you something to show a reviewer and something to re-check later if your normalization rules change.

Alongside the raw MIME you already hold (see preserving raw multipart MIME for /v1/parse), the verbatim phrase makes a mistaken value easy to spot: "04/05" next to "2027-04-05" is a one-glance review. Keep the original email in your own storage so a human can open it. Store it under your own retention rules, since raw email often contains personal data.

A schema that keeps the three kinds apart

This schema models a meeting-style email. Every key is required, and the schema can represent every field as null, including date_text, so “the email did not say” has a place to go. The reasoning is the same as in optional vs. required fields: make absence representable, so the honest answer is not ruled out.

{
  "type": "object",
  "required": [
    "date_text",
    "event_date",
    "local_time",
    "timezone_text",
    "event_start"
  ],
  "properties": {
    "date_text": {
      "type": ["string", "null"],
      "description": "The date and time phrase exactly as written in the email, or null if none."
    },
    "event_date": {
      "type": ["string", "null"],
      "format": "date",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "Calendar date as YYYY-MM-DD. Null if the day, month, or year is missing or the order of day and month is unclear."
    },
    "local_time": {
      "type": ["string", "null"],
      "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
      "description": "Wall-clock time as 24-hour HH:MM exactly as written, without converting zones. Null if no time is given."
    },
    "timezone_text": {
      "type": ["string", "null"],
      "description": "The time zone or offset exactly as written (for example EST or UTC-05:00). Null if the email names none. Never guess."
    },
    "event_start": {
      "type": ["string", "null"],
      "format": "date-time",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$",
      "description": "RFC 3339 timestamp with an explicit offset. Only set when date, time, and a zone or offset are all stated. Otherwise null."
    }
  },
  "additionalProperties": false
}

A few choices here are deliberate:

  • format: "date" for the day, format: "date-time" for the instant. The format names document intent, and they separate the two kinds in your data model. time is deliberately not used for local_time: RFC 3339’s time format includes an offset, which is the thing you may not have. A plain HH:MM string with a pattern says what you mean.
  • The pattern lines carry the enforcement. JSON Schema treats format as an annotation by default in recent drafts, and whether a given validator asserts it depends on its configuration. Do not assume format rejects a bad value. The pattern constraints are ordinary schema validation, and your own code (below) re-checks the values anyway.
  • description is guidance, not a guarantee. The wording “Never guess” and “Null if the email names none” tells the extraction what you want. Test it against real emails from your sources before relying on it, because a description does not make a wrong value impossible.
  • event_start is the only instant, and its pattern requires an offset. A timestamp with no zone cannot satisfy it.

Calling /v1/parse

The request is the same shape as any inline-schema call: schema and raw_mime. Here are two synthetic emails, one explicit and one ambiguous. The first goes through curl:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer $MAILFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "schema": {
      "type": "object",
      "required": ["date_text", "event_date", "local_time", "timezone_text", "event_start"],
      "properties": {
        "date_text": { "type": ["string", "null"] },
        "event_date": { "type": ["string", "null"], "format": "date", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
        "local_time": { "type": ["string", "null"], "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
        "timezone_text": { "type": ["string", "null"] },
        "event_start": { "type": ["string", "null"], "format": "date-time", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$" }
      },
      "additionalProperties": false
    },
    "raw_mime": "From: pm@example.com\r\nTo: team@example.com\r\nSubject: Kickoff call\r\n\r\nKickoff call: Thursday, November 12, 2026 at 3:00 PM EST (UTC-05:00)."
  }'

The schema above is shortened for the command line; it drops the description strings but keeps every constraint. In your application, send the full document, descriptions included.

For the explicit email, a response might look like this. The field names id, status, validation_errors, and data follow the current API shape; the values are illustrative, since they depend on the email and the extraction run:

{
  "id": "parse_a41c07",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "date_text": "Thursday, November 12, 2026 at 3:00 PM EST (UTC-05:00)",
    "event_date": "2026-11-12",
    "local_time": "15:00",
    "timezone_text": "EST (UTC-05:00)",
    "event_start": "2026-11-12T15:00:00-05:00"
  }
}

For the ambiguous email, whose body reads only Reminder: your onboarding session is on 04/05 at 10:00., the honest result is mostly null. Again, illustrative:

{
  "id": "parse_b72e19",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "date_text": "04/05 at 10:00",
    "event_date": null,
    "local_time": "10:00",
    "timezone_text": null,
    "event_start": null
  }
}

This is a valid result. Nothing failed, and validation_errors is empty. It is also a result you cannot schedule from, because the email never said which day-first or month-first order was meant, which year, or which zone. The schema let the extraction say so instead of forcing a timestamp.

Decide in your code, not in the schema

Everything past this point is application policy. The schema describes what shapes are allowed. Whether 04/05 should be read as April 5 because your customers are all in the United States, or whether a missing zone should default to the recipient’s zone, is a business rule that only you can make and defend.

Three rules keep that policy honest:

  1. Never silently fill in a zone. If you do apply a default, store it as your own assumption (for example assumed_timezone: "America/New_York", assumed_by: "account_default"), separate from the extracted fields. A later reader can then tell what the email said from what you decided.
  2. Re-validate the values yourself. Do not rely on the schema alone to reject an impossible date or an offset that disagrees with the rest of the record.
  3. Route doubt to review, not to a default. A held record costs a person a few seconds. A wrong meeting time costs more.

The excerpt below shows the review decision. It is framing code, not a drop-in module. holdForReview and saveEvent stand in for your own queue and storage, and rawMime is the raw email you already keep. It never touches your API key, so there is nothing secret in it.

// holdForReview, saveEvent: provided by your application.
type ParsedDates = {
  date_text: string | null;
  event_date: string | null;
  local_time: string | null;
  timezone_text: string | null;
  event_start: string | null;
};

// A real calendar day: rejects 2027-02-30 instead of letting it roll over to March.
function isRealDate(ymd: string): boolean {
  const [y, m, d] = ymd.split("-").map(Number);
  const t = new Date(Date.UTC(y, m - 1, d));
  return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
}

function reviewReasons(data: ParsedDates): string[] {
  const reasons: string[] = [];

  if (data.event_date !== null && !isRealDate(data.event_date)) {
    reasons.push("event_date is not a real calendar date");
  }
  if (data.event_start !== null) {
    if (Number.isNaN(Date.parse(data.event_start))) {
      reasons.push("event_start is not a parseable instant");
    }
    // The leading YYYY-MM-DD of an RFC 3339 string is the date in the stated offset,
    // so it should match event_date when both are present.
    const startDay = data.event_start.slice(0, 10);
    if (data.event_date !== null && startDay !== data.event_date) {
      reasons.push("event_start and event_date disagree");
    }
    // Defense in depth: the JSON Schema above already requires an offset on event_start.
    if (data.timezone_text === null && !/(Z|[+-]\d{2}:\d{2})$/.test(data.event_start)) {
      reasons.push("instant without a stated zone or offset");
    }
  }
  // A time with no date, or a date with no usable time, cannot be scheduled.
  if (data.event_start === null && (data.event_date === null || data.local_time === null)) {
    reasons.push("incomplete: no instant and no full local date and time");
  }
  // An unresolved local time is fine to keep, but it is not an instant.
  if (
    data.event_start === null &&
    data.timezone_text === null &&
    data.event_date !== null &&
    data.local_time !== null
  ) {
    reasons.push("local time with no zone stated in the email");
  }
  return reasons;
}

// result is the parsed JSON body from POST /v1/parse; rawMime is your stored copy of the email.
const { data, status, validation_errors = [] } = result;

if (status !== "completed" || validation_errors.length > 0) {
  await holdForReview({ rawMime, status, validation_errors });
} else {
  const reasons = reviewReasons(data);
  if (reasons.length > 0) {
    await holdForReview({ rawMime, data, reasons });
  } else {
    await saveEvent(data);
  }
}

Applied to the two examples: the explicit email passes every check and is saved with its offset. The ambiguous email is held with only "incomplete: no instant and no full local date and time", since event_date is null, and the reviewer sees date_text and the original message side by side. For failures of the request itself, such as a 400, a 422, or a 429, see handling errors and timeouts from POST /v1/parse. Those are a separate branch from the date logic here.

What this does not cover

  • Relative dates. “Next Tuesday” or “tomorrow at noon” depend on when the email was sent. The sent time is in the email’s own Date header, which you hold in your stored raw MIME, so resolving a relative phrase is something your code can do against that header. Treat it like any other assumption: record that you did it, and keep the original phrase.
  • Zone abbreviations. “CST” can mean several different zones, and “IST” is similarly ambiguous. If your sources use abbreviations, your policy needs to say which reading wins, or to send them to review. Prefer a named zone or numeric offset when the sender provides one.
  • Daylight saving transitions. A stated offset fixes the instant. A local time plus a named zone near a clock change can be ambiguous or nonexistent, which is one more reason to apply those rules in application code with a proper time zone library.
  • Calendar invites. If your emails carry structured calendar data, see parsing calendar invite emails to JSON for how the fields in that case are usually shaped.

Next steps

  • Split date fields by meaning: a format: "date" calendar date, an HH:MM local time, and a format: "date-time" instant that requires an offset.
  • Make every date field required and nullable, and extract the source phrase next to it.
  • Add pattern constraints for the shapes you rely on, since format alone may not be enforced by a given validator.
  • Re-validate dates in your own code: real calendar day, parseable instant, date and instant agreeing with each other.
  • Decide your assumptions policy (default zones, day-first or month-first) in writing, and store any assumption separately from the extracted values.
  • Hold uncertain records for a person instead of defaulting, and keep the original email with them.
  • Run a sample of your own real emails, including the vague ones, through /v1/parse and read the output by hand before trusting the schema.

The request and response shapes are in the API docs. For the broader schema workflow, start with building a JSON Schema email parser.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access