Guix system and Guix home introduce the concept of services. These provide users
with a way to control background processes, commonly refereed as
daemons, as well as ways of
controlling the setup of files. For example,
openssh-service-type
is a service which controls a SSH
daemon. In contrast,
etc-service-type
is a service that populates the contents of the /etc directory.
One peculiarity of Guix services is that it's customary to provide Scheme bindings for the different fields. By that I mean that the different fields of the configuration of most services will be a type. The benefit of this is that users get a uniform configuration language for their services, at the cost of additional complexity when developing the service. Which is why, for a long time, Guix users seem to struggle with defining complex configurations. There are a number of reasons for this, we will try to close the gap today by going through defining a configuration for Goimapnotify.
This blog post assumes that the reader is somewhat familiar with Guix and knows how to setup a development environment for it. If that's not the case, read The Perfect Setup and Using Guix interactively.
A Goimapnotify configuration is written in YAML. Therefore, we will need to serialize the different Guile Scheme fields to this format. Let's take the example configuration that the author gives in the project's README.md:
configurations:
- host: example.com
port: 143
tls: true
tlsOptions:
rejectUnauthorized: false
starttls: true
idleLogoutTimeout: 15
username: USERNAME
alias: ExampleCOM
password: PASSWORD
xoAuth2: false
boxes:
- mailbox: INBOX
onNewMail: 'mbsync examplecom:INBOX'
onChangedMail: 'mbsync examplenet:INBOX'
onChangedMailPost: SKIP
onNewMailPost: SKIP
- hostCMD: COMMAND_TO_RETRIEVE_HOST
port: 993
tls: true
tlsOptions:
rejectUnauthorized: true
starttls: true
username: ''
usernameCMD: ''
password: ''
passwordCMD: ''
xoAuth2: false
onNewMail: ''
onNewMailPost: ''
onChangedMail: ''
onChangedMailPost: ''
onDeletedMail: ''
onDeletedMailPost: ''
boxes:
- mailbox: INBOX
onNewMail: 'mbsync examplenet:INBOX'
onNewMailPost: SKIP
onChangedMail: 'mbsync examplenet:INBOX'
- mailbox: Junk
onNewMail: 'mbsync examplenet:Junk'
onNewMailPost: SKIPJust by looking at the hierarchy we can already envision how to organize our scheme records. We will need the following:
home-goimapnotify-configuration-fieldsgoimapnotify-configurationgoimapnotify-box-configurationgoimapnotify-tls-options-configuration
The final configuration that the service for Goimapnotify will rely on, will be
home-goimapnotify-configuration-fields. It will contain a configurations
field where each item will be a goimapnotify-configuration; each box in those
configurations will be a goimapnotify-box-configuration. Additionally, each of
those configurations will have an optional
goimapnotify-tls-options-configuration.
The (gnu services configuration) module provides us with the API that we need
to define these configurations. The most important helpers for defining
configurations are:
define-configuration: For configurations that need to serialize fields into a different format, generally configuration files.define-configuration/no-serialization: For configurations that do not need to emit any files. Generally all the fields are consumed by the Shepherd services that rely on the configuration but no translation from Scheme to a different format is needed.
In our case, we need to translate the different configuration fields to YAML, so
we will need to use define-configuration.
The following sections are written so you can follow along, you are encouraged to drop into a REPL (short for read-eval-print loop) and import the required module:
,use (gnu services configuration)goimapnotify-tls-options-configuration
To make it easy for ourselves, we will start form the in-out, after all, you wouldn't want to build a house from the rooftop, would you?
(define-configuration goimapnotify-tls-options-configuration
(reject-unauthorized?
(boolean #f)
"Whether to reject unauthorized TLS certificates.")
(starttls?
(boolean #f)
"Whether to use STARTTLS.")
(prefix goimapnotify-))evaluating the above snippet will throw an unbound variable exception, bear with me.
Refer to Refer to the
manual
for an in-depth explanation of the syntax of define-configuration.
The first argument is the name of the configuration object,
goimapnotify-tls-options-configuration in this case. After it, we define the
different typed fields. We are defining boolean fields that are, by default, set
to false. The biggest source of confusion when defining configurations comes
because define-configuration is a macro that introduces identifiers that do
not appear in the source code—it's an unhygienic
macro. That
means that the macro will expand to code which defines symbols that are not
visible when reading the source file.
One can inspect the expansion of the macro by using the ,expand EXP REPL command. It will be quite verbose, so don't try to read all of it, instead search through it; you will find some revealing things, such as:
scheme@(gnu home services mail)> ,expand (define-configuration goimapnotify-tls-options-configuration
(reject-unauthorized?
(boolean #f)
"Whether to reject unauthorized TLS certificates.")
(starttls?
(boolean #f)
"Whether to use STARTTLS.")
(prefix goimapnotify-))
$20 = (begin ...
(define goimapnotify-tls-options-configuration? ...)
(define goimapnotify-tls-options-configuration-reject-unauthorized? ...)
(define goimapnotify-tls-options-configuration-starttls? ...)
...
(define <goimapnotify-tls-options-configuration> ...)
...
(define goimapnotify-tls-options-configuration ...)
(define goimapnotify-tls-options-configuration-fields
((@@ (gnu services configuration) list)
(let* ((name (let ((x 'reject-unauthorized?)) x)) ...
(serializer (let ((x goimapnotify-serialize-boolean)) x)) ...)
...)
(let* ((name (let ((x 'starttls?)) x)) ...
(serializer (let ((x goimapnotify-serialize-boolean)) x)) ...)
...))))In the previous snippet, ... represents omitted code. As you can see from the
macro expansion, define-configuration introduces quite a few identifiers. You
may recognize the shared prefix in those identifiers, that's right, it's the
prefix specified by that last field that we didn't explain from the
configuration definition, (prefix goimapnotify-).
By inspecting the macro expansion, it's easy to understand what happens under the hood. When a prefix is specified, we instruct the macro to append that prefix to all the generated identifiers. This is useful to avoid naming collisions; for example, when defining the configuration in a module with other configuration records that serialize to different formats. After all, it's not the same to serialize to YAML than to INI, or any other format a tool may require.
Remember that unbound variable problem I mentioned before? If you paid close
attention to the macro expansion, you may have noticed those references to
goimapnotify-serialize-boolean, those are our unbound variables. The macro
expects us to define these serialization procedures, let's do that.
Defining the missing serializers
Since the configuration machinery knows nothing about the output format, we must
define how this translation happens. In our case, we are translating to YAML. By
looking at the example configuration provided by the Goimapnotify developers, we
can see that the field names are written in camel case, and their respective
values are separated from the names through the : character.
Helpers
Let's start by making a function that takes a symbol and transforms it into a string that's a camelized version of that symbol:
(define (camelize-field-name field-name)
(let ((str (object->camel-case-string field-name)))
(if (string-suffix? "?" str)
(string-drop-right str 1)
str)))
;; Usage: (camelize-field-name 'reject-unauthorized?) => "rejectUnauthorized"In YAML, there is no convention of suffixing booleans with a ?, so our
camelizer drops it when found.
camelize-field-name gives us a field name, but we want to serialize the value
too. Let's define a field serialization procedure to help us:
(define (goimapnotify-serialize-field field-name val)
"The mapping is used to serialize certain FIELD-NAMES specially."
(let* ((field-name-mapping '((host-command . hostCmd)
(user-name . username)
(user-name-command . usernameCmd)
(password-command . passwordCmd)))
(field-name* (or (assq-ref field-name-mapping field-name)
field-name)))
(format #f "~a: ~s~%"
(camelize-field-name field-name*)
val)))
;; Usage: (goimapnotify-serialize-field 'reject-unauthorized? 'true) => "rejectUnauthorized: true\n"Notice how we introduced field-name-mapping to tailor the field name passed to
camelize-field-name to emit the specific naming that Goimapnotify expects. In
Guix, we have a specific naming convention, so we want fields like user-name
to map to username (instead of userName) and fields like password-command
to map to passwordCmd (instead of passwordCommand).
That's enough to serialize most values to YAML, but there's an extra Guix-specific feature we should make use of to make the service more convenient to users: G-Expressions.
Are you familiar with them? If not, I encourage you to read this wonderful trilogy of blog posts: Dissecting Guix. It took me some time to wrap my head around these concepts, but once you do, I think you will like them too. In any case, that serializer is using those expressions because we want users of our configuration to be able to intermingle packages and other file-like objects in their fields. Since this is a blog post about writing configurations, I won't dive deep into G-Expressions, but let's try to get you a sense for them.
You will need to import (guix gexp) for the following snippet to work.
(define (goimapnotify-serialize-field field-name val)
"The mapping is used to serialize certain FIELD-NAMES specially."
(let* ((field-name-mapping '((host-command . hostCmd)
(user-name . username)
(user-name-command . usernameCmd)
(password-command . passwordCmd)))
(field-name* (or (assq-ref field-name-mapping field-name)
field-name)))
#~(format #f "~a: ~s~%"
#$(camelize-field-name field-name*)
#$val)))That's not so bad, is it? What was that? 6 more characters? Surely you wouldn't be scared of that, but just in case, let me give you some extra reassurance on what's happening here. This G-Expression thing, also known as gexp, is just the way you let Guix know that this code is for later. "When is later?", you may wonder. Simplifying it, that "later" is when Guix knows where file-like objects will be located in your disk; that long path on the store you may have seen before.
For example, a package is a file-like object, so when you build the package Guix will tell you where it was stored:
$ guix build cowsay
/gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4Preceding an expression with #~ (or gexp), one effectively tells Guix "don't
evaluate this code until you know what this expression should expand to". And
that other syntax, #$ (or ungexp), is telling Guix to replace the file-like
object for its lowered representation, usually a path in the store. For a
package like cowsay, that would be that
/gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4 paths we saw before.
With that said, if you are like me, you won't be comfortable if you cannot debug this expressions, so let me give you some supper powers.
In your REPL, import the (guix) module. This will augment the REPL with some
additional commands that will simplify our life:
scheme@(guile-user)> ,use (guix)
scheme@(guile-user)> ,help guix
Guix Commands [abbrev]:
,run-in-store EXP - Run EXP through the store monad.
,verbosity LEVEL - Change build verbosity to LEVEL.
,lower OBJECT - Lower OBJECT into a derivation or store file and return it.
,build OBJECT [BUILD-MODE] - Lower OBJECT and build it, returning its output file name(s).
,build-options OPTIONS - Set build options to OPTIONS. Print previous value (to allow easy restore).
,build-graft GRAFT? - Set whether grafts should be performed.
,enter-store-monad - Enter a REPL for values in the store monad.
,phases - Return the build phases of the package defined by FORM.
,configure-flags - Return the configure flags of the package defined by FORM.
,make-flags - Return the make flags of the package defined by FORM.Let's see what that gexp is up to:
scheme@(gnu home services mail)> (goimapnotify-serialize-field 'reject-unauthorized? ''true)
$6 = #<gexp (format #f "~a: ~s\n" #<gexp-input "rejectUnauthorized":out> #<gexp-input (quote true):out>) gnu/home/services/mail.scm:252:2 7f4c7d9deb40>There is a little helper to approximate a gexp to it's output:
scheme@(gnu home services mail)> (gexp->approximate-sexp $6)
$7 = (format #f "~a: ~s\n" "rejectUnauthorized" (quote true))
scheme@(gnu home services mail)> (primitive-eval $7)
$8 = "rejectUnauthorized: true\n"That gets us an idea of what will be emitted to disk, but for more complex procedures, this is not going to cut it, specially if there are multiple gexps combined. So let's build that gexp:
;; 'gexp->file' comes from the '(guix gexp)' module. You can ask the REPL more
;; information about a symbol through ',a SYMBOL'.
scheme@(gnu home services mail)> ,a gexp->file
(guix gexp): gexp->file #<procedure gexp->file (name exp #:key guile set-load-path? module-path splice? system target)>
scheme@(gnu home services mail)> (gexp->file "test.scm" $6)
$8 = #<procedure 7f3f7d456ea0 at guix/gexp.scm:2098:2 (state)>In order for Guix to build a value, it needs to be something Guix can lower—an
object that can be "compiled" down to a file in the store (these are referred to
as file-like objects, because they can be inserted in any piece of code that
expects a file name). A gexp cannot be built by itself, because there is nowhere
to output it to. The above snippet creates a file-like object that will be
emitted to a file named test.scm in the store. That file will contain our
expression. As you see, we've got a procedure. The REPL printer tells us that
this procedure needs some state; this is just a way of indicating you that
this is a monadic procedure that can only be run in the context of a store
connection. Read The Store
Monad for
more information.
I will build it manually just once for demonstration purposes, don't blink:
scheme@(gnu home services mail)> ,use (guix store)
scheme@(gnu home services mail)> (run-with-store (open-connection)
$8)
$9 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7f3f7e2de000>Where we are saying "run the procedure we got in the context of a store
connection", we opened that connection through open-connection. Let's not do
that again... Fortunately we have those useful REPL commands I just told you
about, so we can archive the same result by doing this:
scheme@(gnu home services mail)> ,run-in-store $8
$10 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7fcd92f7f0f0>That's better, isn't it?
Now, this is a derivation. That's something Guix can build:
scheme@(gnu home services mail)> ,build $10
building /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv...
$11 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm"What's that? You don't want this magic command? Okay, the same thing can be done with:
scheme@(gnu home services mail)> (run-with-store (open-connection)
(built-derivations (list $10)))
$12 = #t
scheme@(gnu home services mail)> (derivation->output-path $10)
$13 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm"At last, a file! What's in there?
$ cat /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm
(format #f "~a: ~a\n" "rejectUnauthorized" "true")Okay that seems about right. We can even run it:
scheme@(gnu home services mail)> (call-with-input-file $13
(lambda (port)
(primitive-eval (read port))))
$14 = "rejectUnauthorized: true\n"You've seen the secret sauce, let's continue with what brought us here.
(define (goimapnotify-serialize-boolean field-name val)
(goimapnotify-serialize-field field-name (if val ''true ''false)))With this our configuration won't complain about the missing serializer. Let's continue.
One last thing, notice how, after moving to the gexp version of
goimapnotify-serialize-field, we started double quoting the symbols true and
false, this is because the #$ syntax will replace the value in place, and
the value of 'true is true, without the quote. If we didn't double quote,
the staged code after expansion would look like this:
(format #f "~a: ~s\n" "rejectUnauthorized" true)When what we really want is to expand to this:
(format #f "~a: ~s\n" "rejectUnauthorized" 'true)The reason for this is that we want users to be able to write staged code on the different fields. If one of the values of a field where to be something like this:
(goimapnotify-serialize-field 'favorite-game #~(string-append #$cowsay "/bin/cowsay"))The expansion would be this:
(format #f "~a: ~s\n" "favoriteGame" (string-append "/gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4" "/bin/cowsay"))The string-append procedure is evaluated right before the format call, not
when the gexp is getting lowered.
Serializing
The (gnu services configuration) module we imported earlier provides us with
serialize-configuration, it takes two arguments, a configuration object and the
fields that compose that configuration.
The define-configuration macro defined the
goimapnotify-tls-options-configuration constructor for us, we can create a
configuration with the default values like this:
scheme@(gnu home services mail)> (goimapnotify-tls-options-configuration)
$10 = #<<goimapnotify-tls-options-configuration> reject-unauthorized?: #f starttls?: #f %location: #f>Then, we can serialize it like this:
scheme@(gnu home services mail)> (serialize-configuration $10 goimapnotify-tls-options-configuration-fields)
$11 = #<gexp gnu/services/configuration.scm:165:2 7ff1fd2cbf60>If you recall form the macro expansion we saw earlier,
goimapnotify-tls-options-configuration-fields, was one of those identifiers
that got generated.
You should already know how to build that gexp we've got:
scheme@(gnu home services mail)> (gexp->file "test.yaml" $11)
$12 = #<procedure 7efd8b10c2d0 at guix/gexp.scm:2098:2 (state)>
scheme@(gnu home services mail)> ,use (guix)
scheme@(gnu home services mail)> ,run-in-store $12
$13 = #<derivation /gnu/store/z59sf3nhh80668z8njljfxcymgi071pr-test.yaml.drv => /gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml 7efd8a80fd70>
scheme@(gnu home services mail)> ,build $13
$14 = "/gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml"
scheme@(gnu home services mail)> (call-with-input-file $14
(lambda (port)
(display (primitive-eval (read port)))))
rejectUnauthorized: false
starttls: falseThat's some nice YAML syntax... We better speed up the pace or I will retire before finishing up this blog post.
goimapnotify-box-configuration
Next in line is goimapnotify-box-configuration, you know the drill. We start
by declaring the configuration with define-configuration, specifying each
field name, type and docstring:
(define-configuration goimapnotify-box-configuration
(mailbox
string
"The mailbox to monitor.")
(on-new-mail
maybe-string-or-gexp
"The command to execute when new mail arrives.")
(on-new-mail-post
maybe-string-or-gexp
"The command to execute after the new-mail command.")
(on-changed-mail
maybe-string-or-gexp
"The command to execute when mail is changed.")
(on-changed-mail-post
maybe-string-or-gexp
"The command to execute after the changed-mail command.")
(on-deleted-mail
maybe-string-or-gexp
"The command to execute when mail is deleted.")
(on-deleted-mail-post
maybe-string-or-gexp
"The command to execute after the deleted-mail command.")
(prefix goimapnotify-))You may have noticed that we have two new types. strings, easy enough, and
maybe-string-or-gexp, not so easy; right? Worry not, here comes the
explanation.
Let's start by defining the string type:
(define-maybe string (prefix msmtp-configuration-))You may be very confused right now. What has msmtp-configuration to do with
our goimapnotify-configuration example? Well, I want this blog post to get you
ready for the real world, and in the wild, you will make configurations in
modules that contain other configurations. The example we are looking up today
is a narration of my adventures making the Goimapnotify service from the (gnu services mail) module, in that module, there is already a configuration for
msmtp.
With that said, if we tried to do this:
(define-maybe string (prefix goimapnotify-))We would be surprised with a warning that looks something like this:
$ make
...
[ 94%] GUILEC gnu/home/services/mail.go
gnu/home/services/mail.scm:67:0: warning: shadows previous definition of `maybe-string?' at gnu/home/services/mail.scm:63:0
gnu/home/services/mail.scm:278:0: warning: shadows previous definition of `goimapnotify-serialize-maybe-string' at gnu/home/services/mail.scm:67:0That's unexpected, isn't it? Nothing we've seen so far points to a
maybe-string? procedure, let's look at what is going under the hood of that
define-maybe macro:
scheme@(gnu home services mail)> ,expand (define-maybe string (prefix msmtp-configuration-))
$15 = (begin
(define (maybe-string? val)
(or ((@@ (gnu services configuration) not)
((@@ (gnu services configuration) maybe-value-set?) val))
(string? val)))
(define (msmtp-configuration-serialize-maybe-string field-name val)
(if (string? val)
(msmtp-configuration-serialize-string field-name val)
"")))Do you see it? The macro is expanding to some code that introduces two new
procedures into the module, one following the prefix, that would be
msmtp-configuration-serialize-maybe-string, and one that just declares the
predicate for the maybe type.
Given that, the warning is now apparent. If we call again that macro with a
string as the first argument, we will get the same predicate after the
expansion, leading to the redefinition warning.
So, for this particular case, instead of using the macro, we will define manually the two procedures required for our configuration:
;; The module already had this helper defined.
(define (string-or-gexp? obj)
(or (string? obj)
(gexp? obj)))
;; ... omitted lines ...
(define (goimapnotify-serialize-string field-name val)
(goimapnotify-serialize-field field-name val))
(define (goimapnotify-serialize-maybe-string field-name val)
(if (maybe-value-set? val)
(goimapnotify-serialize-string field-name val)
""))
(define goimapnotify-serialize-string-or-gexp
goimapnotify-serialize-string)
(define (goimapnotify-serialize-maybe-string-or-gexp field-name val)
(if (and (maybe-value-set? val)
(string-or-gexp? val))
(goimapnotify-serialize-string-or-gexp field-name val)
""))The above snippet defines all the serializers we need for the new types. Notice
that they are just some simple wrappers around the
goimapnotify-serialize-field helper. That procedure is doing all the heavy
lifting here, and fortunately for us, it already handles gexps. Therefore, the
fields that have a type that accepts a gexp are straightforward to declare.
That would be it for the goimapnotify-box-configuration declaration. As showed
in the previous section, the REPL is your friend. I had never written such a
complex configuration before, but thanks to this elegant weapon for a more
civilized age, I could find my way by poking things
around.
Let's continue!
goimapnotify-configuration
You know the drill, we start by defining the fields we need. Again, I'm doing this just by looking at the README example provided by the developers of Goimapnotify:
(define-configuration goimapnotify-configuration
(host
string
"The IMAP server hostname.")
(host-command
maybe-string-or-gexp
"The command to retrieve the IMAP server hostname.")
(port
(integer 993)
"The port that the IMAP server listens on.")
(tls?
(boolean #f)
"Enable or disable TLS.")
(tls-options
maybe-goimapnotify-tls-options-configuration
"TLS options for the IMAP connection."
(serializer serialize-maybe-goimapnotify-tls-options-configuration))
(idle-logout-timeout
maybe-integer
"The idle logout timeout in minutes.")
(user-name
maybe-string
"The user-name for authentication.")
(user-name-command
maybe-string-or-gexp
"The command to retrieve the user-name.")
(alias
maybe-string
"An alias for the account.")
(password
maybe-string
"The password for authentication.")
(password-command
maybe-string-or-gexp
"The command to retrieve the password.")
(xo-auth2?
(boolean #f)
"Enable or disable XOAUTH2 authentication.")
(wait
maybe-integer
"The delay in seconds before the mail syncing is triggered.")
(boxes
list-of-goimapnotify-boxes-configurations
"The mailboxes to monitor."
(serializer serialize-list-of-goimapnotify-boxes-configurations))
(prefix goimapnotify-))You are already familiar with most of those types, but there are some new things here:
integerandmaybe-integermaybe-goimapnotify-tls-options-configurationlist-of-goimapnotify-boxes-configurations
Let's start with the obvious ones first.
integer
The module already had a maybe definition for the integer. If you recall from
the macro expansion earlier, that means that there is already a symbol for the
predicate maybe-integer?:
(define-maybe integer (prefix msmtp-configuration-))We still need a serializer that follows our prefix:
(define (goimapnotify-serialize-integer field-name val)
(goimapnotify-serialize-field field-name val))
(define (goimapnotify-serialize-maybe-integer field-name val)
(if (maybe-value-set? val)
(goimapnotify-serialize-integer field-name val)
""))Simple enough. Moving on!
maybe-goimapnotify-tls-options-configuration
(define (serialize-goimapnotify-tls-options-configuration field-name val)
(let ((serialization (serialize-configuration val goimapnotify-tls-options-configuration-fields)))
#~(begin
(use-modules (ice-9 format) (ice-9 string-fun))
(format #f "~a:
~a~%"
'#$(camelize-field-name field-name)
(string-replace-substring #$serialization "\n" "\n ")))))
(define-maybe goimapnotify-tls-options-configuration)You are already familiar with the define-maybe macro. The serializer is also
quite simple. Since we already defined a record that contains within all the
required information to serialize it, we just need to make a simple wrapper
around serialize-configuration.
Before moving forward, notice how the serializers from this section doesn't
contain that goimapnotify- prefix. This is an arbitrary decision, but since
there is already goimapnotify in the symbol name, I think it's a bit redundant
to add it. For the configuration definition to know which serializer to use, we
have to be specific in the serializer argument of the fields, that's why in the
configuration we had this declaration specifying a (serializer ...) for the
field:
(define-configuration goimapnotify-configuration
;; ... omitted lines ...
(tls-options
maybe-goimapnotify-tls-options-configuration
"TLS options for the IMAP connection."
(serializer serialize-maybe-goimapnotify-tls-options-configuration))
;; ... omitted lines ...
(prefix goimapnotify-))list-of-goimapnotify-boxes-configurations
First we need a prefix to know if we have a list of
goimapnotify-box-configuration objects, that one is simple:
(define (list-of-goimapnotify-boxes-configurations? lst)
(and (not (null? lst))
(every goimapnotify-box-configuration? lst)))Now we need a serializer that knows how to handle a list of these objects:
(define (serialize-list-of-goimapnotify-boxes-configurations field-name value)
(let ((serializations (cons 'list
(map (cut serialize-configuration <>
goimapnotify-box-configuration-fields)
value))))
#~(begin
(use-modules (ice-9 format) (ice-9 string-fun))
(format #f "~a:
~{ - ~a~%~}"
'#$(camelize-field-name field-name)
(map (lambda (s)
(string-replace-substring s "\n" "\n "))
#$serializations)))))Looks a bit daunting, but it's just staged code, remember our earlier explanation of gexps. This code is only constructing a string with the shape we need.
As a reminder, we are serializing to YAML, that means that lists are prefixed by
a - character. Following the Goimapnotify README, the indentation would be
something like this:
boxes:
- mailbox: INBOX
onNewMail: 'mbsync examplenet:INBOX'
onNewMailPost: SKIP
onChangedMail: 'mbsync examplenet:INBOX'
- mailbox: Junk
onNewMail: 'mbsync examplenet:Junk'
onNewMailPost: SKIPThis is what that format call is doing. Refer to
Formatted-Output
for more information.
We are only lacking a way to generate a complete configuration, remember that
goimapnotify-configuration is an object for a single configuration. According
to the README of Goimapnotify, the configuration file can take a list of
configurations.
home-goimapnotify-configuration
This is the last configuration we will need, it will be used directly by the service:
(define-configuration home-goimapnotify-configuration
(goimapnotify
(file-like goimapnotify)
"The @code{goimapnotify} package to use."
empty-serializer)
(configurations
(list-of-goimapnotify-configurations)
"A list of @code{goimapnotify-configuration} records which contain
information about all your accounts configurations."))Simple enough, the first field is the Guix package that provides the
goimapnotify program. This one is used by the service to start the
process. Since it doesn't need to appear in any configuration file, we don't
need to serialize it, hence the empty-serializer.
On last serializer:
(define (serialize-list-of-goimapnotify-configurations field-name value)
(let ((serializations (cons 'list
(map (cut serialize-configuration <>
goimapnotify-configuration-fields)
value))))
#~(begin
(use-modules (ice-9 format) (ice-9 string-fun))
(format #f "~a:
~{ - ~a~%~}"
'#$(camelize-field-name field-name)
(map (lambda (s)
(string-replace-substring s "\n" "\n "))
#$serializations)))))The rationale for this code is the same as the one explained for
list-of-goimapnotify-boxes-configurations.
Defining the Shepherd service
The service definition; at last!
(define (home-goimapnotify-shepherd-service config)
(let ((log-file #~(string-append %user-log-dir "/goimapnotify.log")))
(list
(shepherd-service
(provision '(goimapnotify))
(modules '((shepherd support))) ;for '%user-log-dir'
(documentation "Run a goimapnotify process")
(start #~(make-forkexec-constructor
(list
#$(file-append
(home-goimapnotify-configuration-goimapnotify config)
"/bin/goimapnotify")
"-conf" #$(mixed-text-file "goimapnotify.yaml"
(serialize-configuration config
home-goimapnotify-configuration-fields)))
#:log-file #$log-file))
(stop #~(make-kill-destructor))))))Refer to Shepherd Services for more information on how to write Shepherd services. I will only highlight how to handle the configuration we just wrote.
We have already done all the hard work, the configuration declaration contains
all the information needed to perform the serialization of the different fields
to YAML, we just need to call serialize-configuration. For example:
scheme@(gnu home services mail)> (define test-config
(home-goimapnotify-configuration
(configurations
(list
(goimapnotify-configuration
(host "test.example.com")
(boxes
(list
(goimapnotify-box-configuration
(mailbox "Test")))))))))
scheme@(gnu home services mail)> (serialize-configuration test-config home-goimapnotify-configuration-fields)
$6 = #<gexp gnu/services/configuration.scm:165:2 7f3448e678a0>That serialization gives a gexp ready to be wrapped in a file-like so it can be
lowered to the store. That's what mixed-text-file is doing:
scheme@(gnu home services mail)> (mixed-text-file "goimapnotify.yaml"
(serialize-configuration test-config
home-goimapnotify-configuration-fields))
$7 = #<<computed-file> name: "goimapnotify.yaml" gexp: #<gexp guix/gexp.scm:2171:6 7f343915ef60> guile: #f options: (#:local-build? #t)>
scheme@(gnu home services mail)>We've got ourselves a service, but there is one last thing before we go!
Defining the service type
As mentioned in the previous section, refer to Defining Services for what's going on here. The last thing we need do is declare the relation that this service has with respect to others:
(define home-goimapnotify-service-type
(service-type
(name 'home-goimapnotify)
(extensions
(list (service-extension home-shepherd-service-type
home-goimapnotify-shepherd-service)))
(description "Configures the @code{goimapnotify} IMAP Mailbox notifier.")))We did it!
Closing words
Still here? That was long... But here we are, we defined our complex configuration.
Congratulations on reading till the end, by this time you should already be an expert on defining Guix configurations.
We acknowledge that there are some improvements to do on the API for defining configurations. We would like to have a way to specify symbol mapping for field names in the declaration, so that serializers can be generalized better. It would also be nice if we could refactor some of our configuration definitions so the serializers that can be generalized are reused between declarations. After all, there are many configuration files in similar formats.
For all of these improvements, we are counting on you! I wrote this blog post to empower you to participate in the development. If this is something that resonates with you, come join the fun!
07 September, 2026 07:48AM by Sergio Pastor Pérez

