# 2023-35: 从飞书导出日历到 Fastmail

在 Databend Labs，我们主要通过飞书进行日常沟通和任务协调。对于需要语音交流的会议，我们通过日历功能来统一安排时间。我个人则更倾向于使用 Fastmail，用其服务管理邮件和日程。因此，我开始考虑能否将飞书的日历同步到 Fastmail，以便在一个平台上统一管理所有日程。

理论上，这并不复杂：

- 飞书提供了 CalDAV 来支持日历同步
- Fastmail 允许订阅 CalDAV 服务

看似只需在 Fastmail 上适当配置 CalDAV 即可。然而，现实是每次尝试配置飞书的 CalDAV 到 Fastmail 时都会遇到报错。以前我对此不以为意，但这个周末，我决定彻底解决这个问题。

# TL; DR

飞书暴露的 CalDAV 实现并不标准，对客户端的 Auto Discovery 行为有依赖。解决方案是手动请求飞书 CalDAV 服务，获取到真实的地址再配置。

首先发送 PROPFIND 到 `/<username>` 获取飞书生成的随机日历 ID

```shell
curl -v -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/
```

在响应中会有形如 `<D:response><D:href>/calendars/<uuid>/</D:href></D:response>` 的输出，这里的 `/calendars/<uuid>/` 就是飞书为个人日历生成的随机路径。

然后在 CalDAV 客户端配置如下即可：

- Username: `<username>`
- Password: `<password>`
- Server URL: `https://caldav.feishu.cn/calendars/<uuid>/`

# 背景介绍

在日历同步领域我们主要遇到以下标准：

## CalDAV

[`CalDAV`](https://en.wikipedia.org/wiki/CalDAV) 是基于 [`WebDAV`](https://en.wikipedia.org/wiki/WebDAV) 的扩展，是 HTTP 扩展的一部分，允许用户或应用程序读取和写入存储在远程服务器上的日历数据。CalDAV 的主要应用场合是在多个设备或多个应用之间同步日历信息，包括事件、提醒和其他相关数据。常见的服务如 Google Calendar 和 Apple iCloud 都支持 CalDAV。

## iCal

`iCal` 是一个文件格式标准，正式名为 iCalendar（.ics 文件），用于存储日历事件、提醒和其他时间相关信息。这种文件格式被广泛接受和支持，可以用于导入、导出和共享日历数据。在日历服务中我们最常用的订阅日历的方式就是通过 `iCal`。

# 漫长的调试

根据飞书提供的文档 [个人用户如何设置本地系统日历到飞书日历的单向同步](https://www.feishu.cn/hc/zh-CN/articles/360043178673-%E4%B8%AA%E4%BA%BA%E7%94%A8%E6%88%B7%E5%A6%82%E4%BD%95%E8%AE%BE%E7%BD%AE%E6%9C%AC%E5%9C%B0%E7%B3%BB%E7%BB%9F%E6%97%A5%E5%8E%86%E5%88%B0%E9%A3%9E%E4%B9%A6%E6%97%A5%E5%8E%86%E7%9A%84%E5%8D%95%E5%90%91%E5%90%8C%E6%AD%A5)，并根据一些最基础的信任，我们可以先假定飞书这个功能的实现是正确的，它没有在文档里面说瞎话，也就是飞书的 CalDAV 功能在 macOS 和 Windows 10 上是正常的，那 Fastmail 不正常只能是 CalDAV Client 具体实现上的一些差异。

通过查询文档和 ChatGPT，我们能够知道 CalDAV 最简单的使用方式是通过 `PROPFIND` 来查询指定条目的属性：

> 飞书的实际输出是压缩后的，这里进行了格式化

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/</D:href>
    </D:response>
</D:multistatus>
```

我们预期这里输出的应该是根路径的属性，但是这里只有一个 `href`。虽然还不知道该怎么继续，但是我们已经能够知道请求是正常响应的，只不过内容不符合预期。根据已有的输出，我猜想飞书使用了 calendarserver 进行了一些魔改。再加上飞书这个 CalDAV 应该是多租户的，所以我问 ChatGPT，如果有多个用户的话，该如何请求？

```openai
> calendarserver 如果有多个用户的话，他们的用户路径是什么样的？

在多用户环境中使用CalendarServer，一种常用的开源日历服务器，通常涉及为每个用户设置单独的访问路径和账户。这通常通过一种叫做路径路由的机制来实现，该机制允许每个用户通过特定的URL路径来访问其各自的日历数据。

假设CalendarServer的基本URL是 `https://calendar.example.com/`，多个用户（比如 `Alice` 和 `Bob`）可能会有如下的用户路径：

- Alice: `https://calendar.example.com/calendars/users/alice/`
- Bob: `https://calendar.example.com/calendars/users/bob/`

在这些路径下，各用户通常会有多个子路径，用于表示个人日历、工作日历、假期日历等。例如，Alice 的个人日历可能位于 `https://calendar.example.com/calendars/users/alice/personal/`。

这样的路径结构有助于实现多用户环境下的访问控制、数据隔离和个性化设置。这通常通过与后端数据库或者身份验证服务（如LDAP或OAuth）的集成来进一步强化。

综上所述，多用户的CalendarServer通常会为每个用户提供一个专有的URL路径，用于访问他们各自的日历数据和设置。这样的设计有助于在多用户环境中实现安全和数据管理。
```

好，现在我们大胆猜想飞书沿用了同样的设计：

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/users/<username>
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
</D:multistatus>
```

很遗憾，没有猜对。访问 `/calendars/users/<username>` 是一个完全空的结果，那 `/calendars/users/` 呢？

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/users/
< HTTP/1.1 400 Bad Request
< Server: TLB
< Content-Length: 0
< Connection: keep-alive
```

有趣，直接报错了，那更进一步，访问 `/calendars/` 会输出什么呢？

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/calendars/</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/</D:href>
    </D:response>
</D:multistatus>
```

有变化了！我们拿到了一个新的 href，指向了一个 uuid，我们延续这个思路继续请求：

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/calendars/<uuid>/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/calendars/<uuid>/d0f47ac4-a047-4589-b7c1-9af6e3cc471b.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/bb010e10-cd76-4213-9d47-28a9bf48417e.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/c0e28572-ed2e-447c-88ed-251dd437eb72.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/2fb352a3-37d3-4487-aea2-dbc1b18ec371.ics</D:href>
    </D:response>
    <D:response>
        <D:href>/calendars/<uuid>/c987311c-3fee-4be5-881c-7d8e5113deea.ics</D:href>
    </D:response>
</D:multistatus>
```

好，我们现在拿到了一系列指向 `ics` 的路径，看起来每一个 ics 指向了一个具体的事件。我使用 `https://caldav.feishu.cn/calendars/<uuid>/` 作为 Server URL 尝试连接，发现 Fastmail 成功连上了飞书的 CalDAV 并正确的获取到了事件！

# 万物皆草台班子

好，飞书已经成功连上了，但是我还是非常好奇，为什么刚刚好是 `/calendars/<uuid>` 呢？看着 PROPFIND 返回的结果，我尝试了一下访问 `/`：

```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn//
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>//</D:href>
    </D:response>
    <D:response>
        <D:href>//<uuid>/</D:href>
    </D:response>
</D:multistatus>
```

蛤？我简直不敢相信自己的眼睛，随后我尝试了一些其他的可能：


```shell
> curl -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" -u "<username>:<password>" https://caldav.feishu.cn/feishu_is_really_cool/
<?xml version="1.0" encoding="UTF-8"?>
<D:multistatus
    xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:ICAL="http://apple.com/ns/ical/"
    xmlns:ME="http://me.com/_namespace/">
    <D:response>
        <D:href>/feishu_is_really_cool/</D:href>
    </D:response>
    <D:response>
        <D:href>/feishu_is_really_cool/61AEE45F-1E58-401C-61AE-E45F1E58401C/</D:href>
    </D:response>
</D:multistatus>
```

我懂了：飞书的 CalDAV 实现是如此的草台，以至于它只有在根路径下工作不正确。

# 总结

本文分享了我调试飞书 CalDAV 的全过程，感谢 ChatGPT 的大力支持和飞书团队给予我的惊喜～
