ICS feeds
An ICS feed is a calendar published as a file at an address: a country's public holidays, a league's fixture list, a colleague's availability, or the "secret address in iCal format" that Google and Outlook hand out for a calendar they will not grant API access to. plMail can follow one, re-read it in the background, and show it beside your own calendars.
This is the cheapest calendar connection there is. There is nothing to register, no administrator step, no credential and no consent screen — one address is the whole configuration.
Subscribing
Settings → Calendars → Subscribe to an address, or the same button from the calendar itself.
Calendar address is the only field that matters: the address the calendar is published at,
usually ending in .ics. A webcal:// link works too — it is the same address under another name,
and plMail rewrites it before anything else sees it.
Name is optional. Left empty, the calendar names itself from the feed's own title, and failing that from the file name in the address. A name already in use gains a number rather than being refused, because two feeds you did not name are not a collision you should have to resolve.
There is no "which calendars?" step, because a feed is one calendar. Subscribing shows a single row, which is the honest picture of what a published address offers.
The feed is fetched once, at that moment. If the address turns out not to be a calendar, nothing is
left behind — the connection is removed again rather than sitting in your settings as a permanently
broken row with nothing to correct but the address itself. Which is the most likely failure by some
distance: a Subscribe button copies a link to a web page about as often as it copies a link to
an .ics, and the two are indistinguishable until something tries to parse one.
webcal:// is https under another name
Apple registered the scheme; no client has ever spoken a protocol by that name. A webcal:// URL is
fetched by replacing the scheme and doing an ordinary GET, and plMail maps it to https, not to
the http the original note implied. Two reasons, in order: every publisher still offering webcal
today also serves the same path over TLS, and mapping to http would run the address into the
plaintext refusal below — so a user pasting exactly the link their calendar gave them would be told
to ask their administrator for permission. webcals:// exists in the wild too and means the same
thing.
Only the scheme is rewritten, not the rest of the URL. Feed addresses routinely carry a query string
with its own :// inside a percent-encoded parameter, and a rewrite that matched anywhere would
mangle that instead.
Read-only, always
A subscribed calendar cannot be edited here, and this is a fact about what an ICS feed is rather than a setting somebody could change. The far end is a static file: there is no method that would write to it and no server that would accept one. Every other calendar plMail connects to is asked whether it accepts writes — CalDAV reads the collection's privileges, Google reads the access role — because those remotes have an opinion. A file at a URL does not.
The practical consequence: you can see the events, they appear in your views, and the calendar is marked read-only in the interface. Events cannot be added to it, and nothing you do here can ever reach the publisher.
The address is the credential. For a published calendar there is nobody to authenticate as, so anyone holding the address can read the calendar — which is exactly why Google and Outlook call theirs a secret address. plMail warns about this on the subscribe form and does not render the stored address back afterwards. Treat one like a password, and regenerate it at the publisher if it gets out.
How often it is re-read
Feeds are re-read by the scheduled calendar sweep, which runs every fifteen minutes and picks up any calendar that has not been synced in the last fourteen. There is no push and there is nothing to configure: an ICS feed has no notification mechanism to register for, so nothing about a public address, a reverse proxy or a webhook applies here.
Re-reading a whole calendar every fifteen minutes sounds expensive and is not, because a feed has no
delta mechanism and HTTP has validators. plMail stores the ETag and Last-Modified of the last
successful read and sends them back as If-None-Match and If-Modified-Since, and an unchanged
calendar answers 304 with no body. That is the answer to almost every poll of a holiday calendar.
When the feed has changed, plMail re-reads it from scratch — two downloads for one actual change. That is deliberate. A feed states what exists and says nothing about what was removed, so applying it as a delta would keep every cancelled fixture forever; the full re-read is what makes a deletion land.
To pull one immediately rather than waiting:
docker compose exec php php bin/console app:calendar:sync CALENDAR_ID
Why some addresses are refused
plMail fetches this address server-side, on a schedule, from a container that can reach its own database, its message broker and its workers. An address a user types is therefore checked before anything requests it — the same guard that protects every other user-supplied server address, and for the same reason.
Refused outright:
A scheme that is not http or https |
ftp://, or a bare example.com/feed.ics with no scheme at all |
http:// |
unless INTEGRATIONS_ALLOW_HTTP is on |
| A username or password in the URL | it would be logged wherever the URL is |
localhost, or any host ending .localhost |
|
| Loopback, link-local and private ranges | 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0/8, and the IPv6 equivalents ::1, fc00::/7, fe80::/10 |
The private-range block is the one that catches self-hosters, and it is the check that stops
somebody aiming plMail at its own database container or at a cloud metadata endpoint at
169.254.169.254. A feed on your own LAN is refused until you allow it, by naming the host in
INTEGRATIONS_ALLOWED_HOSTS — for example nextcloud.lan or 10.0.0.5 — and, if the server has no
certificate, by also setting INTEGRATIONS_ALLOW_HTTP. Both are in the
configuration reference.
Understand what each costs. INTEGRATIONS_ALLOWED_HOSTS exempts exactly the hosts you name and
nothing else, which is narrow and is the right tool for one feed on one internal machine.
INTEGRATIONS_ALLOW_HTTP is global: it makes every integration and every feed in the installation
able to use plaintext, so a credential can cross your network in the clear. It is off by default so
that sending one stays a deliberate choice rather than an accident.
Redirects are re-checked, every hop. plMail follows up to three, by hand, and puts every one
back through the same validation before fetching it. Letting the HTTP client chase them would mean a
perfectly public feed host answering 302 Location: http://169.254.169.254/… turns the scheduled
poll into a read of the cloud metadata endpoint. The redirect limit itself covers the shapes that
actually occur — http to https, an apex to a www host, a short link.
A feed larger than 8 MB is refused while it downloads, not after. That is far past the point where a feed is plausibly a calendar — a national holiday feed is under 60 KB, a fifteen-year corporate room calendar a few hundred — and it exists so an address a user typed cannot kill a worker with an unbounded response.
Once a feed is reachable, the far end can still say no, and plMail distinguishes what is worth retrying:
| Answer | What plMail does |
|---|---|
401 |
Gives up permanently. A feed has no sign-in; use the secret or public address instead. |
404, 410 |
Gives up permanently. There is no calendar there any more. |
429, 503 |
Backs off and retries, honouring Retry-After when it is given in seconds. |
403 |
Retries. Unlike a CalDAV server there is no credential here to have been rejected, and a CDN in front of a feed answers 403 for a rate limit, a geo rule and a bot filter alike. |
| A dead host, a TLS error, a timeout | Retries. A publisher that is down comes back. |
Things that bite
The link the Subscribe button gave you may be a web page. Publishers put a human-readable
calendar page and an .ics behind buttons that look the same. If subscribing fails with a message
about the address not holding a calendar, open it in a browser: if you get a page, the .ics is
somewhere else on it.
A feed on your own network is refused, and that is the SSRF guard rather than a bug. Name the
host in INTEGRATIONS_ALLOWED_HOSTS. Turning off the private-range block entirely is not an option
on purpose, and INTEGRATIONS_ALLOW_HTTP is a bigger hammer than most people need.
The address is the credential and is not shown again. Anyone holding it can read the calendar. If a secret address leaks, regenerate it at the publisher — there is nothing to revoke on this side.
A subscribed calendar cannot be edited, and no permission fixes that. If you need to write to a Google or Outlook calendar, connect the account itself rather than its published address; see Google and Microsoft.
Nothing pushes. A change at the publisher appears here within fifteen minutes, and no amount of reverse-proxy configuration makes it faster. This is the one calendar source where that is not a degraded state.
A feed with no X-WR-CALNAME names itself after its file. feiertage-deutschland in your
sidebar is that, and typing a name when you subscribe is how to avoid it.
Two downloads per change is expected. A poll that finds the feed changed re-reads it from scratch, because that is the only way a deletion in a published file can be detected at all.