Calendar
How Viauna keeps calendars and events, and how it handles times and time zones. For using the calendar, see Calendar.
Where calendars are kept
Viauna keeps each mailbox's calendars itself, in its state store, with no other server to set up. There is no copy anywhere else, so back up the state store: see Upgrades and backups.
- The mailbox's own calendar is made the first time its calendar is opened, named after the mailbox (its name in Settings, or its address). Only one is made, even when two pages open at the same moment. It can be renamed, not deleted.
- Each calendar has a name, a colour, a place in the list and whether it's shown. Hiding one is kept with the mailbox, so it's hidden in every browser.
- Deleting a calendar deletes its events.
Each event is kept whole
An event is kept as its iCalendar object (RFC 5545): the event and, later, any changed occurrences of it, exactly as made. Beside it are its title, a lower-cased text to search (title, place and description, accents removed) and when it starts and ends. To change an event, Viauna changes only the fields it shows, and leaves everything else (guests, alarms, another app's own properties) as it was.
| In Viauna | In the event |
|---|---|
| Title, location, description | SUMMARY, LOCATION, DESCRIPTION |
| Starts, ends | DTSTART, DTEND (an event that had DURATION gets DTEND instead) |
| Busy or free | TRANSP: OPAQUE or TRANSPARENT |
| Colour | COLOR (RFC 7986), as a CSS colour name; none means the calendar's |
| Video call link | CONFERENCE (RFC 7986), with FEATURE=VIDEO |
| How it repeats | RRULE, and EXDATE for occurrences deleted; one changed on its own is a VEVENT of its own with a RECURRENCE-ID |
- A new event gets a random
UID,SEQUENCE:0, andDTSTAMP,CREATEDandLAST-MODIFIEDset to the time. Each change setsDTSTAMPandLAST-MODIFIEDagain. - Changing an event's time adds 1 to its
SEQUENCE, which is how a calendar that has a copy (an invitation, say) tells a newer version from an older one (RFC 5546). Changing anything else doesn't. - Google writes its video links as
X-GOOGLE-CONFERENCE, and Teams asX-MICROSOFT-SKYPETEAMSMEETINGURLorX-MICROSOFT-ONLINEMEETINGCONFLINK. Viauna shows those too; changing or removing the link replaces it withCONFERENCE. - An event's end before its start counts as no end. With no end, an all-day event lasts its day and a timed one is a moment (RFC 5545 3.6.1).
Times and time zones
A timed event is written the iCalendar way: a wall-clock time and the zone it's in, DTSTART;TZID=Europe/London:20261021T090000. Its end can be in another zone (a flight). Viauna writes the zone's IANA name and no VTIMEZONE definition, which Google's and Apple's servers and Thunderbird accept. Reading, it takes times as they come:
| Written as | Read as |
|---|---|
TZID= an IANA name | That time in that zone, converted with the IANA time-zone database |
TZID= another name (Outlook's W. Europe Standard Time) | Through the VTIMEZONE sent with it; without one, as floating |
A time ending in Z | UTC |
| A time with neither ("floating") | That time wherever you are |
A date (VALUE=DATE) | All day, on that date wherever you are |
The page sends its time zone with every request: the one in Settings > Calendar, or the browser's. Times come back in it, and the page sends them in it. When someone moves an event that was made in another zone, the new time is converted into that zone and kept there, so a meeting in London moved by someone in Singapore stays a London meeting at the right time. The editor shows an event in its own zones, and saving from it writes the zones it shows. The second time zone's hours are worked out at the offsets of the first day in view.
A time that a clock change skips (01:30 on the night clocks go forward an hour) is moved forward by the gap; one that happens twice (clocks going back) is the first of the two.
Repeating events
An event repeats by its rule (RRULE), less the dates left out (EXDATE), with any occurrence changed on its own kept as a VEVENT of its own, named by the occurrence it replaces (RECURRENCE-ID). An occurrence is named by that time as written, 20261021T090000, and the page asks for one by it.
Working out the occurrences. ical.js walks the rule in the event's own wall-clock time, so a weekly meeting at 09:00 London time is at 09:00 every week, and each occurrence then becomes an instant in its zone, before and after the clocks change. ical.js knows no time zones itself, so the rule's end and the dates left out, which other apps often write in UTC, are first put in the event's zone; otherwise it would compare London's 09:00 with 08:00 UTC and lose the last occurrence. A yearly event on 29 February happens in leap years only, as RFC 5545 says. Occurrences are worked out from the first, at most 50,000 of them (a daily event for 130 years).
The editor's choices are the rules it can show: daily, weekly on chosen days, monthly on a date or on the first to fourth or last weekday, yearly, every so many of each, until a date or so many times. A rule beyond those (another app's BYSETPOS, say, or extra dates with RDATE) is kept as written, shown as made in another app, and can be replaced but not edited.
Changing one occurrence:
| What happens | |
|---|---|
| This event | The occurrence becomes a VEVENT of its own, with its RECURRENCE-ID; changing it again changes that one |
| This and following events | The series is split: the original ends just before the occurrence (its UNTIL, or its COUNT made the number before it), and a new event carries on from it with the same rule (its COUNT what's left), the dates left out after it, and the occurrences changed on their own after it. Its UID is the original's with _R and the occurrence added. From the first occurrence, it's all events |
| All events | The series moves as the occurrence did: by as many days, to its new time of day, in its new zone. The dates left out, the names of occurrences changed on their own, a weekly or monthly rule's weekdays and dates, and the rule's end all move the same way, so they still match. An occurrence changed only in other ways (its title, say) moves with the series; one moved on its own stays put. A new title, place, description, colour, busy or free, or video link reaches the occurrences that had the old one |
Deleting: this event adds it to the dates left out (and removes its own VEVENT); this and following ends the series before it; all events, or the last occurrence left, deletes the event. A new rule can't be for one occurrence, and one occurrence can't move to another calendar: those are for this and following events, or all of them.
Reminders
A reminder is an alarm (VALARM) in its event, as RFC 5545 has it: ACTION:DISPLAY with a DESCRIPTION, and a TRIGGER some time before (-PT10M) or after the start. An all-day event's starts at midnight, so "1 day before at 09:00" is -PT15H. Viauna reads and edits the alarms that fire relative to the start and show something (DISPLAY, or AUDIO, which a page can't play, so it's shown) or send an email (EMAIL). Others (after the end, at a set time, or running a program) are kept as they are, and not sent; REPEAT isn't followed. An occurrence changed on its own has its own alarms. Alarms nobody changed stay exactly as written.
Sending. Each event keeps when its next reminder is due. Saving an event sets that to "now", for the scheduler to work out; every 30 seconds it takes the events whose time has come, claiming each first (as calendars from the web are claimed, so instances sharing a database don't both send), sends the reminders that came due since it last looked, and works out the next, looking up to 62 days ahead and again after that. A reminder that came due while Viauna wasn't running is sent if it's at most 15 minutes late, and skipped after that. Events in calendars from the web don't remind (their alarms are their owner's), and the Birthdays calendar has none.
Times are worked out in the mailbox's time zone, from Settings > Calendar, or if that's the device's, the zone the calendar was last shown in, or UTC until it has been. That matters for all-day and floating events; an event in a zone of its own reminds at its time there.
On the page: a due reminder goes to every page open on the mailbox over the live-update stream, as reminder. Upgrading from 0.5, events that already had alarms are looked at on the first round, and nothing past is sent.
With Viauna closed (Web Push): a browser that turns them on registers Viauna's service worker (/sw.js), subscribes with its push service, and gives Viauna the subscription: the service's address for it, and its keys. Viauna keeps that per mailbox, up to 20 browsers each (the oldest goes). When a reminder is due, each of the mailbox's browsers is sent a message: its title, times, zone and place, and the page to open, as JSON, encrypted for that browser alone (RFC 8291, aes128gcm), so the push service can't read it. It's posted to the push service with a token signed by this server's key (VAPID, RFC 8292), Urgency: high, and a TTL lasting until the event starts and 5 minutes more (between 5 minutes and a day), so a browser that's offline gets it when it's back if it's still useful. The worker shows it, quietly if a Viauna tab is in view, which shows it too; a click opens /mailbox/<address>/calendar/day/<date>, which the page sends to that mailbox's place in the browser. A page whose mailbox has them on doesn't also show a desktop notification of its own.
- The key pair comes from the server's secret (
SECRET_KEY, orsecret.key), so every instance sharing it signs alike; a new secret means turning them on again in each browser. - The push service's address is the browser's to give, so it's reached like a calendar from the web:
https://only, at public addresses only, 15 s at most. One that answers404or410(the browser unsubscribed) is forgotten, as is one that fails 10 times in a row. - With
BROWSER_NOTIFICATIONS(orCALENDAR) off, the page doesn't offer them and nothing is pushed.
By email: an EMAIL alarm is sent from Viauna's own sender (SENDER_ADDRESS), signed in as it, to the mailbox's own address, and only for mailboxes on IMAP_HOST whose login is the address shown (the one the mail server checked), so the sender can't be made to mail anyone else; the alarm's ATTENDEE is set to that address when it's saved, as RFC 5545 asks, but not followed. The email has the title, times (in the zone above), place and video link, and a link to the day in the calendar if PUBLIC_URL is set, in plain text and HTML, with Auto-Submitted: auto-generated so no out-of-office reply comes back. A sending that fails is logged, not tried again. Without a sender, email reminders aren't offered, and those imported events bring are kept but not sent.
Invitations
Invitations travel by email the standard way (iMIP, RFC 6047): a message whose text/calendar part has a method (iTIP, RFC 5546). REQUEST invites, or brings a newer version; CANCEL calls an event, or one occurrence, off; REPLY is a guest's answer to its organiser; without a method (PUBLISH, or any .ics attached) it's an event to add. Viauna reads the part from the message on the server each time: the card and its buttons never act on anything the page sends but the choice. Of a message's calendar parts, it takes the text/calendar one with a method (an invitation also attaches the same object as invite.ics), up to 1 MB. That part isn't listed as an attachment.
You are the guest whose address is the mailbox's or one it sends as. The version is its SEQUENCE: one lower than the event kept is out of date, and can't be answered; one higher, or the same with other times, can update it.
Answering keeps the event, as the invitation has it, in your calendar (by its UID: in place of the one kept, if there is one), with your PARTSTAT set and RSVP taken off, and a copy of the mailbox's usual reminders if the invitation brought none. Then it sends the organiser a REPLY, from the mailbox, through its SMTP server, as Accepted:, Declined: or Tentatively accepted: and the title: the event's UID, SEQUENCE, times, title and organiser, and you alone as its guest, with your answer. It isn't filed in Sent. No reply is sent if you organise it yourself, or your RSVP is FALSE. Answering from the event's card does the same.
A newer version replaces the one kept, except for what's yours: its reminders, its colour, and your answer, which stays if the first occurrence's times (DTSTART, DTEND, DURATION, RRULE) are the same, and is asked again if not, as RFC 5546 has organisers do when they reschedule. An invitation for one occurrence (a RECURRENCE-ID) replaces that occurrence in the series kept.
A cancellation removes the event kept, or the occurrence it names. A guest's answer, to an event you organise, sets their PARTSTAT in your copy (on the occurrence it names, if it names one); one to an older version is left out.
Sending. Guests set in the editor become ATTENDEEs (ROLE=REQ-PARTICIPANT, PARTSTAT=NEEDS-ACTION, RSVP=TRUE, and their name as CN), on the event and each occurrence changed on its own; the mailbox becomes its ORGANIZER. A guest already there keeps their answer; with none left, the organiser goes too. Only the organiser can change the list. When the page says to email the guests (it asks), Viauna compares the event as it was and as it is: everyone now invited gets the new version as a REQUEST (as Invitation: to those new to it, Updated invitation: to the rest), anyone taken off a CANCEL naming only them, and if the event is gone, everyone a CANCEL. A CANCEL has STATUS:CANCELLED and its SEQUENCE raised. Your reminders aren't sent. Each email goes from the mailbox through its SMTP server, with its own password, and isn't filed in Sent. SEQUENCE goes up when an event's times change (above); other changes keep it, and a receiving app tells them apart by what changed, as Viauna does: a version is newer if its SEQUENCE is higher, or the same with other times, repeats, title, place, description, status or guests.
Guests on the same server. A guest whose mailbox is on this Viauna server gets the same email as anyone else, and it goes into their calendar only when they answer it; nothing is put in anyone's calendar unasked. Once they have, the event stays in step both ways, without waiting for the emails (which go too):
- their answer, then and whenever they change it, is counted in the organiser's event;
- when the organiser sends a change, it's made in their copy as Update your calendar would make it, keeping their reminders, colour and answer; when the organiser takes them off the guest list or deletes the event, their copy goes. Don't send sends nothing and changes nobody's copy.
Both happen only between mailboxes on this server's own mail server (IMAP_HOST), and only for the address it checked at sign-in: the login, where that's the address shown. The address typed in at sign-in proves nothing by itself (a separate username can sign in to one mailbox under another's address), and neither does an alias, which anyone can type in; an answer from either, or a change to a copy that names another organiser, is left for the email. A series split with This and following is a new event, with a new UID, so guests answer it again.
Nothing by itself from mail. Mail that arrives with an invitation, an update, a cancellation or an answer changes nothing until someone presses its card's button: Viauna doesn't read calendar parts as mail arrives, and anyone can send a message that claims to be from anyone.
In the calendar, each event's response is your PARTSTAT when you're one of its guests and not its organiser, and the grid draws it: NEEDS-ACTION outlined, TENTATIVE striped, DECLINED faded and struck through. Events you organise, and events without guests, have none.
Calendar files
Importing a file splits it into events: every VEVENT with the same UID (the event and its occurrences changed on their own) becomes one object, with the VTIMEZONEs it names and the file's PRODID, and is kept as it came, but for the file's METHOD, which says how the file was sent and has no place in a stored event (RFC 4791). An event with no UID gets one made from its content, urn:sha256:…, so importing it twice still finds it. An event already in the calendar with the same UID is replaced, if it changed. VTODO, VJOURNAL and events without a start are skipped, and counted.
Exporting puts a calendar's events in one file, with X-WR-CALNAME (its name), X-WR-TIMEZONE (your zone) and METHOD:PUBLISH, as Google's exports have, and a VTIMEZONE for every zone the events use, which Outlook needs to read a time:
- For a named zone, it's built from the IANA time-zone database: the zone's changes of offset, found by looking at it every week, from the first year an event is in. Changes that follow a yearly pattern (the last Sunday of March, the second Sunday of March, the first Sunday on or after the 2nd, a date) are one observance with a yearly
RRULE, left open ended if the zone still uses it; patterns are checked 28 years ahead, so that one is the zone's rule and not a coincidence of the years looked at. Anything irregular (Morocco's changes around Ramadan) is an observance of its own. - For another app's own zone name (Outlook's
W. Europe Standard Time), the definition the event came with is passed on as it was.
Two library faults are worked around here. ical.js converts a UTC time into a VTIMEZONE's wall-clock time wrongly in the hours before a change of offset, even with Google's own definitions, so Viauna goes the other way (it tries each of the zone's offsets and keeps the one that converts back). And temporal-polyfill's search for a zone's next change misses changes that come close together, so Viauna finds them itself.
Finding what's in a week
Beside each event, its start and end are kept in UTC milliseconds, all-day and floating times read as if they were UTC: for a repeating event, its first occurrence's start and its last one's end, or the largest time a JavaScript date holds if it never ends. A week, a month or a 30-day schedule asks for the events from one date to another in the reader's time zone; Viauna takes those overlapping that range with a day to spare either side, then places each one exactly in the reader's zone. One request covers at most 400 days.
The Birthdays calendar
A calendar's kind says where its events come from: events, kept in the state store as above, or birthdays. Each mailbox gets one Birthdays calendar, the first time its calendar is opened while CONTACTS is on, and only one, however many pages open at once. It's never stored as events: when the calendar asks for a range of dates, Viauna makes an all-day event for each date in the mailbox's contacts that falls in it, from the dates kept beside each contact (see Contacts), so a date changed in Contacts or synced from a phone is right at once.
- A birthday is "Name's birthday", an anniversary "Name's anniversary", any other date "Name: its label" ("Name: a date" without one). With the date's year known, the age or the years follow in brackets, from the year after.
- 29 February is on 28 February in a year without one. Nothing shows before the date's year. A date in words ("circa 1950") isn't a date, so it isn't shown.
- The events are free, not busy, and can't be changed, moved or deleted, nor can events be added, imported or moved into this calendar. It can be renamed, recoloured and hidden, but not deleted. Search doesn't look at it.
- Exported, each date is a yearly all-day event (
RRULE:FREQ=YEARLY) from the year it happened, or from 2000 when that isn't known (a leap year, so 29 February is a date), free (TRANSP:TRANSPARENT), titled without the age. 29 February repeats on the last day of February (BYMONTH=2;BYMONTHDAY=-1). ItsUIDis made from the contact's. - With
CONTACTSoff it isn't listed and its part of the API answers that it doesn't exist; it comes back as it was when Contacts does.
Calendars from the web
A calendar of kind feed comes from an address: its events are fetched from there and kept like any other's, but only Viauna changes them, from what the address gives. So it can't be changed from the page: no adding, changing, moving, deleting or importing events.
Fetching is done by the server, from anywhere it can reach, so the address is checked like a mail server entered at sign-in: only public addresses, unless ALLOW_PRIVATE_SERVERS is on. The name is looked up, every address it has checked, and the connection made to one of them, so an answer that changes between the check and the connection can't point the server at your network. An address written as an IP (in any of its spellings) is checked as it is. Redirects, up to 5, are checked the same way.
webcal://is fetched overhttps://, and, if the server can't be reached that way, overhttp://, since webcal names neither.http://andhttps://are fetched as they are. An address with a name and password in it is refused, so none is kept.- The request says it's Viauna (
User-Agent: Viauna/<version> (calendar; +<the repository>)), asks for gzip, deflate or Brotli, and sends theETagandLast-Modifiedof the last answer (If-None-Match,If-Modified-Since), so a calendar that hasn't changed costs a304 Not Modified. - Limits: 30 s for the whole fetch, redirects included; 10 MB after decompressing (counted as it arrives, so a small compressed file can't unpack into a large one); 10,000 events; 1 MB an event.
Keeping it: the file is split into events as an import is (see Calendar files), its METHOD left out. An event whose UID is new is added, one whose content changed is replaced, and one no longer in the file is deleted, all in one transaction. A file Viauna can't read (a server's error page, say), an answer other than 200 or 304, or any other failure leaves the events as they were and keeps the reason, which the page shows. The calendar is named as the file names itself (X-WR-CALNAME), or after the address's host. The same address can't be added twice.
When it's fetched: when it's added, then every 6 hours, or an hour after a fetch that failed. Every 5 minutes (and 30 s after starting) Viauna looks for calendars that are due, up to 10 at a time, and fetches them one by one. Each is claimed first, by moving its next fetch on only if no one else has, so instances sharing a database don't both fetch it. Refresh now fetches one at once, at most once a minute. With CALENDAR_FEEDS (or CALENDAR) off, nothing is fetched and these calendars are hidden; they come back as they were.
Secret addresses
A calendar's secret address is <your Viauna>/ical/<token>.ics, where the token is 32 random bytes (256 bits) in base64url. Only its SHA-256 is kept, so the address can't be shown again, nor read from a copy of the database; whoever asks with it gets the calendar, without signing in. Making a new one replaces the hash, so the old address stops at once.
- The whole calendar is the calendar as Export makes it, the Birthdays calendar and calendars from the web included, with the time zone of Settings > Calendar (UTC if none) as
X-WR-TIMEZONE. - Only when you're busy keeps of each busy event its
UID, its times (DTSTART,DTEND,DURATION), how it repeats (RRULE,RDATE,EXDATE) andSEQUENCE, withSUMMARY:BusyandCLASS:PRIVATE. An event shown as free (TRANSP:TRANSPARENT) or cancelled isn't included; an occurrence that's free or cancelled on its own is left out of its series with anEXDATE, so its time isn't shown either. - Answers carry an
ETag, andIf-None-Matchwith it gets304 Not Modified;Cache-Control: private, max-age=300, andX-Robots-Tag: noindex. An address that names nothing, or a calendar that's hidden (the Birthdays calendar with Contacts off, one from the web with those off), gets404. - When it was last fetched is noted, at most once a minute, for the calendar's settings.
- With
CALENDAR_SECRET_ADDRESSES(orCALENDAR) off, every address answers404and none can be made; they work again when it's back on. Make a new one to be sure an old one stays stopped.
Undo and other pages
- Moving, changing or deleting an event keeps it as it was, in memory, for 10 minutes; Undo puts it back (a deleted one with the same id; a split series as one again). Not after a restart, nor once its calendar has been deleted.
- A change tells every page signed in to the mailbox, as
calendaron the live-update stream, and they show it at once. - Search looks through every event's title, place and description, and shows the newest 200 matches; a repeating event at its next occurrence, or its last if it's over.
Turning it off
With CALENDAR=false (or the switch on the admin page), the calendar is gone from every page at once, and so is its part of the API. Calendars and events are kept, and come back when it's turned on again.