Commit42722ce4Recorded2 Apr 2026Repositorybureau
Fix batch operation crashes and date search invalidArguments error
Message
Batch ops (archive-batch, trash-batch, move-batch) crashed when comma-separated ID strings contained empty entries from trailing commas, double commas, or whitespace-only segments. Added parse-id-list helper that filters empty entries and errors on empty input.
Date-based email search returned JMAP invalidArguments when dates had partial times (e.g. "2026-04-01T18:30" missing seconds) or timezone offsets (e.g. "+03:00" getting Z appended). Added normalize-utc-date to properly normalize any ISO 8601 variant to JMAP UTCDate format.
Changed
src/bureau/email.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
test/test-bureau.sgl | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 147 insertions(+), 15 deletions(-)Diff
src/bureau/email.sglmodified
@@ -11,7 +11,9 @@
11
(sigil jmap) 12
(bureau connection) 13
(bureau format))−14
(export register-email-tools!)+14
(export register-email-tools!+15
parse-id-list+16
normalize-utc-date) 17
(begin 18
19
;; ============================================================@@ -156,6 +158,59 @@
158
(required . ("id" "name" "path")))) 159
160
+161
;; ============================================================+162
;; Helpers+163
;; ============================================================+164
+165
;; Parse a comma-separated ID string into a list, filtering+166
;; out empty entries (from trailing commas, double commas, etc.)+167
(define (parse-id-list ids-str)+168
(let ((ids (filter (lambda (s) (not (string=? s "")))+169
(map string-trim (string-split ids-str ",")))))+170
(when (null? ids)+171
(error "No valid email IDs provided"))+172
ids))+173
+174
;; Normalize a date string to JMAP UTCDate format (YYYY-MM-DDTHH:MM:SSZ).+175
;; Handles: date-only, datetime with/without Z, partial times, timezone offsets.+176
(define (normalize-utc-date str)+177
(let ((t-pos (string-find str "T")))+178
(if (not t-pos)+179
;; Date only: append midnight UTC+180
(string-append str "T00:00:00Z")+181
;; Has time component: normalize it+182
(let* ((date-part (substring str 0 t-pos))+183
(after-t (substring str (+ t-pos 1) (string-length str)))+184
;; Strip trailing Z if present+185
(after-t (if (string-ends-with? after-t "Z")+186
(substring after-t 0 (- (string-length after-t) 1))+187
after-t))+188
;; Strip timezone offset (+HH:MM or -HH:MM at end of time)+189
(time-part (let ((plus-pos (string-find after-t "+")))+190
(if plus-pos+191
(substring after-t 0 plus-pos)+192
;; Search for minus after the hour digits (position > 2)+193
(let loop ((i (- (string-length after-t) 1)))+194
(if (< i 3) after-t+195
(if (char=? (string-ref after-t i) #\-)+196
(substring after-t 0 i)+197
(loop (- i 1))))))))+198
;; Strip fractional seconds (.NNN)+199
(time-part (let ((dot-pos (string-find time-part ".")))+200
(if dot-pos+201
(substring time-part 0 dot-pos)+202
time-part)))+203
;; Ensure HH:MM:SS format (add :00 if only HH:MM)+204
(colon-count (fold (lambda (acc c)+205
(if (char=? c #\:) (+ acc 1) acc))+206
0+207
(string->list time-part)))+208
(time-part (if (= colon-count 1)+209
(string-append time-part ":00")+210
time-part)))+211
(string-append date-part "T" time-part "Z")))))+212
+213
214
;; ============================================================ 215
;; Tool Handlers 216
;; ============================================================@@ -181,18 +236,10 @@
236
(filter (if subject (dict-set filter subject: subject) filter)) 237
(filter (if text (dict-set filter text: text) filter)) 238
(filter (if after−184
(dict-set filter after:−185
(if (string-contains? after "T")−186
(if (string-ends-with? after "Z") after−187
(string-append after "Z"))−188
(string-append after "T00:00:00Z")))+239
(dict-set filter after: (normalize-utc-date after)) 240
filter)) 241
(filter (if before−191
(dict-set filter before:−192
(if (string-contains? before "T")−193
(if (string-ends-with? before "Z") before−194
(string-append before "Z"))−195
(string-append before "T00:00:00Z")))+242
(dict-set filter before: (normalize-utc-date before)) 243
filter)) 244
(filter (if unread-only 245
(dict-set filter notKeyword: "$seen")@@ -384,7 +431,7 @@
431
(define (tool-email-trash-batch args) 432
(let* ((client (ensure-jmap-client)) 433
(ids-str (dict-ref args ids:))−387
(ids (map string-trim (string-split ids-str ",")))+434
(ids (parse-id-list ids-str)) 435
(trash (or (jmap-mailbox-by-role client "trash") 436
(error "Trash mailbox not found"))) 437
(trash-id (dict-ref trash id:)))@@ -394,7 +441,7 @@
441
(define (tool-email-archive-batch args) 442
(let* ((client (ensure-jmap-client)) 443
(ids-str (dict-ref args ids:))−397
(ids (map string-trim (string-split ids-str ",")))+444
(ids (parse-id-list ids-str)) 445
(archive (or (jmap-mailbox-by-role client "archive") 446
(error "Archive mailbox not found"))) 447
(archive-id (dict-ref archive id:)))@@ -404,7 +451,7 @@
451
(define (tool-email-move-batch args) 452
(let* ((client (ensure-jmap-client)) 453
(ids-str (dict-ref args ids:))−407
(ids (map string-trim (string-split ids-str ",")))+454
(ids (parse-id-list ids-str)) 455
(mailbox-name (dict-ref args mailbox:)) 456
(mb (resolve-mailbox client mailbox-name)) 457
(mb-id (dict-ref mb id:)))test/test-bureau.sglmodified
@@ -11,7 +11,8 @@
11
(sigil caldav) 12
(bureau format) 13
(bureau calendar)−14
(bureau config))+14
(bureau config)+15
(bureau email)) 16
17
18
;; ============================================================@@ -369,3 +370,87 @@
370
(test "credentials-valid? with missing vars" 371
;; When env vars aren't set, should return #f 372
(assert-true (boolean? (bureau-credentials-valid?)))))+373
+374
+375
;; ============================================================+376
;; ID Parsing (batch operations)+377
;; ============================================================+378
+379
(test-group "parse-id-list"+380
+381
(test "simple comma-separated IDs"+382
(assert-equal '("id1" "id2" "id3")+383
(parse-id-list "id1,id2,id3")))+384
+385
(test "IDs with spaces"+386
(assert-equal '("id1" "id2" "id3")+387
(parse-id-list "id1, id2, id3")))+388
+389
(test "trailing comma filtered"+390
(assert-equal '("id1" "id2")+391
(parse-id-list "id1,id2,")))+392
+393
(test "leading comma filtered"+394
(assert-equal '("id1" "id2")+395
(parse-id-list ",id1,id2")))+396
+397
(test "double comma filtered"+398
(assert-equal '("id1" "id2")+399
(parse-id-list "id1,,id2")))+400
+401
(test "whitespace-only entries filtered"+402
(assert-equal '("id1" "id2")+403
(parse-id-list "id1, ,id2")))+404
+405
(test "single ID"+406
(assert-equal '("id1")+407
(parse-id-list "id1")))+408
+409
(test "empty string raises error"+410
(assert-error (parse-id-list "")))+411
+412
(test "only commas raises error"+413
(assert-error (parse-id-list ",,,"))))+414
+415
+416
;; ============================================================+417
;; Date Normalization (JMAP UTCDate)+418
;; ============================================================+419
+420
(test-group "normalize-utc-date"+421
+422
(test "date only"+423
(assert-equal "2026-04-01T00:00:00Z"+424
(normalize-utc-date "2026-04-01")))+425
+426
(test "full UTC datetime passes through"+427
(assert-equal "2026-04-01T18:30:00Z"+428
(normalize-utc-date "2026-04-01T18:30:00Z")))+429
+430
(test "datetime without Z gets Z appended"+431
(assert-equal "2026-04-01T18:30:00Z"+432
(normalize-utc-date "2026-04-01T18:30:00")))+433
+434
(test "partial time (no seconds) gets :00 added"+435
(assert-equal "2026-04-01T18:30:00Z"+436
(normalize-utc-date "2026-04-01T18:30")))+437
+438
(test "partial time with Z"+439
(assert-equal "2026-04-01T14:00:00Z"+440
(normalize-utc-date "2026-04-01T14:00Z")))+441
+442
(test "timezone offset +HH:MM stripped"+443
(assert-equal "2026-04-01T18:30:00Z"+444
(normalize-utc-date "2026-04-01T18:30:00+03:00")))+445
+446
(test "timezone offset -HH:MM stripped"+447
(assert-equal "2026-04-01T18:30:00Z"+448
(normalize-utc-date "2026-04-01T18:30:00-05:00")))+449
+450
(test "fractional seconds stripped"+451
(assert-equal "2026-04-01T18:30:00Z"+452
(normalize-utc-date "2026-04-01T18:30:00.123Z")))+453
+454
(test "fractional seconds with offset stripped"+455
(assert-equal "2026-04-01T18:30:00Z"+456
(normalize-utc-date "2026-04-01T18:30:00.000+03:00"))))