summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-21 15:38:56 -0400
committerCraig Jennings <c@cjennings.net>2026-05-21 15:38:56 -0400
commite78595096c1cb956602796c6b4b692e58458ff99 (patch)
treedd80dca49c7d4df7d3cb57613ff71c9f61351c8b
parent4ac1b8161f7206592fa3d8efbf7eabb5c51b7bc6 (diff)
downloaddotemacs-e78595096c1cb956602796c6b4b692e58458ff99.tar.gz
dotemacs-e78595096c1cb956602796c6b4b692e58458ff99.zip
feat(calendar-sync): resolve .ics feed URLs from auth-source
A calendar's .ics feed URL is a secret token, so I'd rather not keep it in a plaintext config file. A calendar can now name a :secret-host, and calendar-sync--calendar-url looks the URL up in auth-source (~/.authinfo.gpg) at sync time. Inline :url still works and wins when both are set, so existing configs are unaffected. I added 7 tests covering the explicit-url, string-secret, function-secret, precedence, and no-match paths, and switched the .example template to the :secret-host shape.
-rw-r--r--calendar-sync.local.el.example20
-rw-r--r--modules/calendar-sync.el32
-rw-r--r--tests/test-calendar-sync--calendar-url.el86
3 files changed, 128 insertions, 10 deletions
diff --git a/calendar-sync.local.el.example b/calendar-sync.local.el.example
index ba84603b..c4646659 100644
--- a/calendar-sync.local.el.example
+++ b/calendar-sync.local.el.example
@@ -1,8 +1,8 @@
;;; calendar-sync.local.el.example --- Template for private calendar config -*- lexical-binding: t; -*-
;; Copy this file to `calendar-sync.local.el' (sibling of init.el) and
-;; replace the placeholder URLs with your actual private ICS feed
-;; addresses. The real file is gitignored; the template is tracked.
+;; replace the placeholders with your actual private ICS feeds. The real
+;; file is gitignored; the template is tracked.
;;
;; How it works:
;; - `modules/calendar-sync.el' defines `calendar-sync-private-config-file'
@@ -13,6 +13,16 @@
;; - `user-constants' is required earlier in init.el, so `gcal-file',
;; `pcal-file', and `dcal-file' are bound when this file is evaluated.
;;
+;; Two ways to give each .ics calendar its feed URL:
+;; :secret-host - PREFERRED. An auth-source host whose secret holds the
+;; feed URL, looked up in ~/.authinfo.gpg (encrypted at
+;; rest, and distributed with your other authinfo entries).
+;; The .ics URL is itself a secret token, so it belongs in
+;; the encrypted store. Add a line like:
+;; machine calendar-google login me password https://...ics
+;; :url - the feed URL inline (plaintext in this file). If both
+;; are set, :url wins.
+;;
;; Where to find the private .ics URL:
;; - Google Calendar: Settings -> Your Calendar -> Integrate calendar ->
;; "Secret address in iCal format" (regenerate if leaked).
@@ -21,13 +31,13 @@
(setq calendar-sync-calendars
`((:name "google"
- :url "https://calendar.google.com/calendar/ical/YOUR_ADDRESS%40gmail.com/private-XXXXXXXXXX/basic.ics"
+ :secret-host "calendar-google"
:file ,gcal-file)
(:name "proton"
- :url "https://calendar.proton.me/api/calendar/v1/url/XXXXXXXX/calendar.ics?CacheKey=XXXXXXXX&PassphraseKey=XXXXXXXX"
+ :secret-host "calendar-proton"
:file ,pcal-file)
(:name "deepsat"
- :url "https://calendar.google.com/calendar/ical/YOUR_WORK_ADDRESS%40example.com/private-XXXXXXXXXX/basic.ics"
+ :secret-host "calendar-deepsat"
:file ,dcal-file)))
(provide 'calendar-sync.local)
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 2fb1df03..9379b427 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -71,6 +71,7 @@
(require 'cl-lib)
(require 'subr-x)
+(require 'auth-source)
(require 'cj-org-text-lib)
(defun calendar-sync--log-silently (format-string &rest args)
@@ -92,8 +93,13 @@ Each calendar is a plist. Common keys:
:file - Output file path for org format
:fetcher - Fetch path: \\='ics (default) or \\='api
-For the default \\='ics fetcher (Proton, plain .ics feeds):
- :url - URL to fetch the .ics file from
+For the default \\='ics fetcher (Proton, plain .ics feeds), give the feed
+URL one of two ways:
+ :url - the feed URL inline (plaintext in this file)
+ :secret-host - an auth-source host whose secret holds the feed URL,
+ looked up in ~/.authinfo.gpg (encrypted at rest). Prefer
+ this: the .ics URL is itself a secret token. If both are
+ set, :url wins.
For the \\='api fetcher (Google Calendar, sees per-occurrence response
status so OOO auto-declines on recurring events can be filtered):
@@ -1497,11 +1503,27 @@ calendar files do not block the interactive Emacs thread."
(calendar-sync--sync-calendar-api calendar)
(calendar-sync--sync-calendar-ics calendar)))
+(defun calendar-sync--calendar-url (calendar)
+ "Return the .ics feed URL for CALENDAR, or nil if none is configured.
+An explicit :url wins. Otherwise :secret-host names an auth-source host
+whose stored secret is the URL (kept in auth-source because the .ics URL
+is itself a token)."
+ (or (plist-get calendar :url)
+ (let ((host (plist-get calendar :secret-host)))
+ (when host
+ (let ((secret (plist-get (car (auth-source-search :host host :max 1))
+ :secret)))
+ ;; auth-source's netrc backend returns the secret as a function
+ (cond ((functionp secret) (funcall secret))
+ (secret secret)))))))
+
(defun calendar-sync--sync-calendar-ics (calendar)
- "Sync a single CALENDAR from its :url .ics feed asynchronously.
-CALENDAR is a plist with :name, :url, and :file keys."
+ "Sync a single CALENDAR from its .ics feed asynchronously.
+CALENDAR is a plist with :name, :file, and a feed URL resolved by
+`calendar-sync--calendar-url' (an explicit :url, or a :secret-host
+looked up in auth-source)."
(let ((name (plist-get calendar :name))
- (url (plist-get calendar :url))
+ (url (calendar-sync--calendar-url calendar))
(file (plist-get calendar :file))
(fetch-start (float-time)))
(calendar-sync--set-calendar-state name '(:status syncing))
diff --git a/tests/test-calendar-sync--calendar-url.el b/tests/test-calendar-sync--calendar-url.el
new file mode 100644
index 00000000..f18f5b55
--- /dev/null
+++ b/tests/test-calendar-sync--calendar-url.el
@@ -0,0 +1,86 @@
+;;; test-calendar-sync--calendar-url.el --- Tests for feed URL resolution -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for `calendar-sync--calendar-url', which resolves the .ics
+;; feed URL for a calendar plist. An explicit :url wins; otherwise the
+;; URL is looked up in auth-source (e.g. ~/.authinfo.gpg) under the
+;; calendar's :secret-host. auth-source-search is mocked at the boundary
+;; so tests never touch disk or GPG. Covers Normal, Boundary, and Error.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+(defmacro test-cs-url--with-auth (result &rest body)
+ "Run BODY with `auth-source-search' stubbed to return RESULT.
+Records each call's :host argument into `calls', a binding BODY can read."
+ (declare (indent 1))
+ `(let ((calls '()))
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest args)
+ (push (plist-get args :host) calls)
+ ,result)))
+ ,@body)))
+
+;;; Normal
+
+(ert-deftest test-calendar-sync--calendar-url-normal-explicit-url ()
+ "Normal: an explicit :url is returned verbatim."
+ (test-cs-url--with-auth nil
+ (should (equal "https://example.com/feed.ics"
+ (calendar-sync--calendar-url
+ '(:name "x" :url "https://example.com/feed.ics"))))
+ ;; auth-source must not be consulted when :url is present
+ (should (null calls))))
+
+(ert-deftest test-calendar-sync--calendar-url-normal-string-secret ()
+ "Normal: :secret-host resolves via auth-source with a string secret."
+ (test-cs-url--with-auth (list (list :host "calendar-google"
+ :secret "https://g.example/basic.ics"))
+ (should (equal "https://g.example/basic.ics"
+ (calendar-sync--calendar-url
+ '(:name "google" :secret-host "calendar-google"))))
+ (should (equal '("calendar-google") calls))))
+
+(ert-deftest test-calendar-sync--calendar-url-normal-function-secret ()
+ "Normal: a function-valued :secret is funcalled (the netrc backend's form)."
+ (test-cs-url--with-auth (list (list :host "calendar-proton"
+ :secret (lambda () "https://p.example/cal.ics")))
+ (should (equal "https://p.example/cal.ics"
+ (calendar-sync--calendar-url
+ '(:name "proton" :secret-host "calendar-proton"))))))
+
+;;; Boundary
+
+(ert-deftest test-calendar-sync--calendar-url-boundary-explicit-url-wins ()
+ "Boundary: when both :url and :secret-host are set, :url wins and
+auth-source is never consulted."
+ (test-cs-url--with-auth (list (list :host "h" :secret "from-authinfo"))
+ (should (equal "from-url"
+ (calendar-sync--calendar-url
+ '(:name "x" :url "from-url" :secret-host "h"))))
+ (should (null calls))))
+
+;;; Error
+
+(ert-deftest test-calendar-sync--calendar-url-error-neither-key ()
+ "Error: a calendar with neither :url nor :secret-host yields nil."
+ (test-cs-url--with-auth (list (list :host "h" :secret "should-not-reach"))
+ (should (null (calendar-sync--calendar-url '(:name "x" :file "f"))))
+ (should (null calls))))
+
+(ert-deftest test-calendar-sync--calendar-url-error-no-match ()
+ "Error: :secret-host with no auth-source match yields nil."
+ (test-cs-url--with-auth nil
+ (should (null (calendar-sync--calendar-url
+ '(:name "x" :secret-host "calendar-missing"))))))
+
+(ert-deftest test-calendar-sync--calendar-url-error-match-without-secret ()
+ "Error: a match lacking a :secret yields nil, not an error."
+ (test-cs-url--with-auth (list (list :host "calendar-google"))
+ (should (null (calendar-sync--calendar-url
+ '(:name "x" :secret-host "calendar-google"))))))
+
+(provide 'test-calendar-sync--calendar-url)
+;;; test-calendar-sync--calendar-url.el ends here