Skip to content

Snoozing messages outside office hours in Fastmail

I spent an entire(ish) day learning to use a single sieve command. Hopefully this writeup eases your pain if you are on the same path.

Solution

If you just want to see what the solution looks like, here it is. This snoozes messages sent during the weekend or between 6pm and 8am until the next 8am Mon-Fri.

if allof(
  not string :is "${stop}" "Y",
  anyof(
    date :zone "Europe/Helsinki" :value "lt" "received" "hour" "08",
    date :zone "Europe/Helsinki" :value "ge" "received" "hour" "18",
    date :zone "Europe/Helsinki" :is "received" "weekday" ["0", "6"]
  )
) {
  if specialuse_exists "\\Snoozed" {
    snooze
      :tzid "Europe/Helsinki"
      :addflags ["$new"]
      :weekdays ["1","2","3","4","5"]
      ["08:00:00"];
    set "hasmailbox" "Y";
    set "skipinbox" "Y";
  }
}

You don't have to type all of this. You can grab the anyof section and feed it to a Sieve condition when creating a new rule in Fastmail. Adjust timezones as appropriate. Fastmail can generate the rest. If you don't see the Sieve condition option, click "Switch to no-preview rules (regular expressions supported)" below the rules.

But how did you get there?

Finding information about how sieve filters are written ended up surprisingly difficult. The first promising resource I found is Protonmail's sieve filter documentation. It showcases a bunch of things you might want to do, but I found the explanations very vague. Luckily, the last section links to authoritative resources for a bunch of normal sieve packages.

The one we care about is RFC5260 - Date and Index Extensions. This RFC conveniently also links RFC5228, which is the only good resource for learning the sieve language.

The RFC defines two sieve test commands with the following form:

date [<":zone" <time-zone: string>> / ":originalzone"]
     [COMPARATOR] [MATCH-TYPE] <header-name: string>
     <date-part: string> <key-list: string-list>

currentdate [":zone" <time-zone: string>]
            [COMPARATOR] [MATCH-TYPE]
            <date-part: string>
            <key-list: string-list>

These work almost identically, except date grabs the datetime from a specific email header, while currentdate uses the current datetime, at the time the sieve filter is run. If the filter is run on a message that just arrived, date <match-type> "received" and currentdate <match-type> are in practice identical. If running against old messages, the behavior of currentdate can be surprising.

Not all headers can be trusted. For example, Sent is always set by the sender and can hold any garbage value. Received, however, is generally set by the receiving server so we can use it freely.

If timezone is omitted, the filter defaults to the system's local timezone. I could not find which timezone Fastmail uses for sieve.

The :value match type we use is defined in RFC5231 - Relational Extension. It is defined as follows:

VALUE = :value ("gt" / "ge" / "lt" / "le" / "eq" / "ne")

Alright, this is starting to form a picture. date lets us specify how we want to compare, which header we want to compare against and what part of the date we care about. Notable that date :value "eq" is identical to date :is, which is actually identical to date (:is is the default match type).

Section 4.2. of the date extension RFC explains the available date parts. Reproduced here for convenience.

"year"      => the year, "0000" .. "9999".
"month"     => the month, "01" .. "12".
"day"       => the day, "01" .. "31".
"date"      => the date in "yyyy-mm-dd" format.
"julian"    => the Modified Julian Day, that is, the date
               expressed as an integer number of days since
               00:00 UTC on November 17, 1858 (using the Gregorian
               calendar).  This corresponds to the regular
               Julian Day minus 2400000.5.  Sample routines to
               convert to and from modified Julian dates are
               given in Appendix A.
"hour"      => the hour, "00" .. "23".
"minute"    => the minute, "00" .. "59".
"second"    => the second, "00" .. "60".
"time"      => the time in "hh:mm:ss" format.
"iso8601"   => the date and time in restricted ISO 8601 format.
"std11"     => the date and time in a format appropriate
               for use in a Date: header field [RFC2822].
"zone"      => the time zone in use.  If the user specified a
               time zone with ":zone", "zone" will
               contain that value.  If :originalzone is specified
               this value will be the original zone specified
               in the date-time value.  If neither argument is
               specified the value will be the server's default
               time zone in offset format "+hhmm" or "-hhmm".  An
               offset of 0 (Zulu) always has a positive sign.
"weekday"   => the day of the week expressed as an integer between
               "0" and "6". "0" is Sunday, "1" is Monday, etc.

All you have to do then is define which part you care about and what you want it to look like. For example, to match Saturday and Sunday, we can do:

anyof(
  date :is "received" "weekday" "0", # Sunday
  date :is "received" "weekday" "6"  # Saturday
)

Since the matched value can be a list, we can simplify this to the identical version below.

date :is "received" "weekday" ["0", "6"]

I hope that's everything you need to know (and all the resources you need to read) to start using date in sieve.