Emacs is more than my favorite text editor, it is building material. As such, I use it for everything I can, it’s amazing how it always steps up to the challenge!

The internet revived the postal service instead of killing it. Today, I receive more packages than ever before. Tracking where they are doesn’t however fit my workflow. It requires me to open websites, input the tracking id, or at least search for the email with the link. Thus I wondered: Is there a better way to do this in Emacs?

The org-mode workflow

Org-mode is great to track of tasks, it provides an agenda view, it is a workflow I like and it was already the way I kept track of packages. I would create a new entry, have a link to the email with the tracking information. I read my email using notmuch’s Emacs frontend. Then I would click on the link in the Email, which opens the browser. That is a lot of clicks.

How about the org entry has all the tracking information and I can update it on the spot?

Of course! That shouldn’t be a challenge at all. I opened the tracking website. In this case is DHL Germany and looking at the network requests on my browser I found a JSON endpoint which has the tracking information. What a great luck! The problem is now easy to solve: query the endpoint, parse the response, update the org-entry.

Query the endpoint

DHL Germany gives tracking information for unauthenticated users, over a simple GET request at the url: https://www.dhl.de/int-verfolgen/data/search?piececode={track-id}&noRedirect=true&language=en. Simply replace {track-id} with your shipment tracking number and done.

I must clarify, that this in not an official service endpoint, it is a hack. I figured out by looking at their network requests of the tracking webpage. The webpage and this endpoint don’t give all the information. You must first input on the website your postal code to authenticate yourself and get detailed information. I, however, couldn’t figure out how that request works. Since the information I get from this url is good enough for my purposes, I didn’t try any harder, not did I tried the official way using DHL’s API and getting an and account and an API key and all that drama.

The Emacs function for the request is:

 1(defun delivery-track--dhl-de (track-id)
 2  "Async request to dhl API using TRACK-ID.
 3Write update on the org-node in current buffer."
 4  (let* ((url-request-method "GET")
 5         (params `((piececode ,track-id)
 6                   ("noRedirect" true)
 7                   (language "en"))))
 8    (thread-first
 9      "https://www.dhl.de/int-verfolgen/data/search?"
10      (concat (url-build-query-string params))
11      (url-retrieve #'delivery-track--process-response
12                    (list (current-buffer) #'delivery-track-entry--dhl)))))

Parse the response

Next is an auxiliary function to parse the JSON response and pass that to a dedicated function which extracts the necessary data. Once the data is in the appropriate format, it goes again to the function that updates the org entry.

1(defun delivery-track--process-response (_request-status buffer provider-parser)
2  "Process API response with PROVIDER-PARSER and update into BUFFER."
3  (goto-char url-http-end-of-headers)
4  (apply #'delivery-track-org-entry
5         buffer
6         (funcall provider-parser (json-parse-buffer))))

Let us focus then on the provider-parser argument which in our case is the function given earlier delivery-track-entry--dhl. It processes the response object to return a 3 element list, having the tracking ID again, the current status and a list of timestamped events.

 1(defun delivery-track-entry--dhl (response)
 2  "Parse DHL RESPONSE into standard delivery-track info for writer."
 3  (let* ((shipment-info (seq-find
 4                         (lambda (item)
 5                           (eq t (gethash "hasCompleteDetails" item)))
 6                         (gethash "sendungen" response)))
 7         (history (thread-last
 8                    shipment-info
 9                    (gethash "sendungsdetails")
10                    (gethash "sendungsverlauf"))))
11    (list
12     (gethash "id" shipment-info)
13     (gethash "kurzStatus" history)
14     (thread-last
15       (gethash "events" history)
16       (seq-keep (lambda (event)
17                   (when-let ((time (gethash "datum" event)))
18                     (list time (gethash "status" event)))))
19       (reverse)))))

Update the org-entry

This is where it gets ugly. I’m finally mutating state and editing the org buffer. delivery-track-org-entry must first find which org-entry to update. It does so by going to the buffer where the entry should be, and finding it by id. It is a design decision to make the shipping/tracking number the id of the entry, that way I can use standard org functions to find the entry. That all happens in lines 11-12.

 1(defun delivery-track-write-time! (timestring)
 2  "Insert an org inactive timestamp from a parse-able TIMESTRING."
 3  (thread-first
 4    timestring
 5    (parse-time-string)
 6    (encode-time)
 7    (org-insert-timestamp t t)))
 8
 9(defun delivery-track-org-entry (buffer shipment-id status events)
10  "Write update on BUFFER the SHIPMENT-ID with STATUS and EVENTS."
11  (with-current-buffer buffer
12    (goto-char (org-find-entry-with-id shipment-id))
13    (org-entry-put nil "status" status)
14    (org-next-visible-heading 1)
15    (seq-let (level _rlevel _todo _prio headline)
16        (org-heading-components)
17      (when (and (= level 2)
18                 (string= "Shipment reverse history" headline))
19        (org-cut-subtree))
20
21      (insert "** Shipment reverse history\n")
22      (seq-doseq (event events)
23        (seq-let (time status) event
24          (delivery-track-write-time! time)
25          (insert " " status "\n"))))))

Then it is all about changing things. First, I update the property status (line 13). Then move forward one heading (line 14). I check if that heading is the subheading containing the shipping history (lines 15-19). If it is, I delete all its contents. If not, I don’t do anything. This is nice, because as long as I use the first sub-tree to be the shipping history, I can write some notes about the packaging before, and also some other subtrees of information.

Lastly, I write in place the shipping history, every event on the list (lines 21-25).

What does it look like?

I only need to create an org entry place and set the property to the tracking number. I additionally write some notes about the shipment, including the org-notmuch links to the emails containing purchase information and the shipment information. The shipment history gets updated with the previous function.

 1* DONE That package
 2:PROPERTIES:
 3:ID:       00340434XXXXXXXXXXXX
 4:status:   Delivery successful.
 5:END:
 6[[notmuch:id:[email protected]][from Store: Thanks for your purchase]]
 7[[notmuch:id:[email protected]][from Store: Your shipment is on the way]]
 8
 9** Shipment reverse history
10[2024-09-26 Thu 14:34] The shipment has been successfully delivered
11[2024-09-26 Thu 08:08] The shipment has been loaded onto the delivery vehicle
12[2024-09-26 Thu 05:54] The shipment has been processed in the delivery base.
13[2024-09-25 Wed 23:23] The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.
14[2024-09-25 Wed 18:46] The instruction data for this shipment have been provided by the sender to DHL electronically
15[2024-09-25 Wed 14:11] The shipment has been processed in the parcel center of origin

A convenient way to use this is creating an interactive function which I can call over the entry.

1(defun delivery-track-update (track-id)
2  "Update tracking information for TRACK-ID.
3Interactive defaults to current buffer's org-node id and provider properties."
4  (interactive (list (read-string "What is the tracking id? " (org-id-get))))
5  (delivery-track--dhl-de track-id))

I can now have this information in my org-agenda as a reminder. Especially if I schedule the date of arrival.

By using org-columns you can view a summary of your shipments. You can call directly that function, or as I show next use a dynamic block.

 1#+COLUMNS: %TODO %ITEM %ID %status
 2# Call org-columns to view summary
 3
 4#+BEGIN: columnview :maxlevel 1 :id global
 5| TODO | ITEM         |                   ID | status               |
 6|------+--------------+----------------------+----------------------|
 7| NEXT | Document     |     RR70479XXXXDE200 |                      |
 8| DONE | Post         |         54504XXXXXXX | Delivery successful. |
 9| DONE | Vitamins     |    3387241124XXXXXXX |                      |
10| DONE | Candy        |         23353XXXXXXX | Delivery successful  |
11| DONE | That package | 00340434XXXXXXXXXXXX | Delivery successful. |
12#+END:

Last thoughts

This is a great tool. I reminds me why I like Emacs, it reminds me that I never need to conform to the way others do things and suffer under their User Interfaces, I can always do it my way.

You can look at all the code in my publicly available dotfiles, specifically this file: delivery-track.el . As of today, it can also track packages by Hermes.

I wanted to deal with packages by DPD, yet their website does not use a JSON endpoint. Their backend already produces a webpage with all the tracking information. I didn’t want to deal with scraping that page, but maybe you will. I’m happy to read about it, if you do.