aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-05-15 16:07:54 +0200
committerRobin Jarry <robin@jarry.cc>2023-06-01 22:15:00 +0200
commit11ccc471bb91e19334fa266f9837f9bb09a1e34d (patch)
treeaec190f4e0f9262985e99c625588c83ec07d6c07
parentac43047d4508b7f375fe6808f8e4826a1ef40210 (diff)
downloadaerc-11ccc471bb91e19334fa266f9837f9bb09a1e34d.tar.gz
aerc-11ccc471bb91e19334fa266f9837f9bb09a1e34d.zip
contrib: add carddav-query script
Add a standalone python script to allow querying contacts from a CardDAV compatible server. The script works with python 3.6+ and has no external dependencies. Link: https://sabre.io/dav/building-a-carddav-client/ Link: https://www.rfc-editor.org/rfc/rfc6352 Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Tim Culverhouse <tim@timculverhouse.com>
-rw-r--r--CHANGELOG.md1
-rw-r--r--Makefile6
-rwxr-xr-xcontrib/carddav-query268
-rw-r--r--doc/aerc-config.5.scd7
-rw-r--r--doc/carddav-query.1.scd103
5 files changed, 382 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 435ddd62..5eed2aef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
in `aerc.conf`.
- IMAP now uses the delimiter advertised by the server
- Completions for `:mkdir`
+- `carddav-query` utility to use for `address-book-cmd`.
### Fixed
diff --git a/Makefile b/Makefile
index e4ca0c45..73d078ca 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,8 @@ DOCS := \
aerc-smtp.5 \
aerc-tutorial.7 \
aerc-templates.7 \
- aerc-stylesets.7
+ aerc-stylesets.7 \
+ carddav-query.1
all: aerc wrap colorize $(DOCS)
@@ -115,7 +116,9 @@ install: $(DOCS) aerc wrap colorize
$(DESTDIR)$(SHAREDIR) $(DESTDIR)$(SHAREDIR)/filters $(DESTDIR)$(SHAREDIR)/templates $(DESTDIR)$(SHAREDIR)/stylesets \
$(DESTDIR)$(PREFIX)/share/applications $(DESTDIR)$(LIBEXECDIR)/filters
install -m755 aerc $(DESTDIR)$(BINDIR)/aerc
+ install -m755 contrib/carddav-query $(DESTDIR)$(BINDIR)/carddav-query
install -m644 aerc.1 $(DESTDIR)$(MANDIR)/man1/aerc.1
+ install -m644 carddav-query.1 $(DESTDIR)$(MANDIR)/man1/carddav-query.1
install -m644 aerc-search.1 $(DESTDIR)$(MANDIR)/man1/aerc-search.1
install -m644 aerc-accounts.5 $(DESTDIR)$(MANDIR)/man5/aerc-accounts.5
install -m644 aerc-binds.5 $(DESTDIR)$(MANDIR)/man5/aerc-binds.5
@@ -168,6 +171,7 @@ RMDIR_IF_EMPTY:=sh -c '! [ -d $$0 ] || ls -1qA $$0 | grep -q . || rmdir $$0'
uninstall:
$(RM) $(DESTDIR)$(BINDIR)/aerc
+ $(RM) $(DESTDIR)$(BINDIR)/carddav-query
$(RM) $(DESTDIR)$(MANDIR)/man1/aerc.1
$(RM) $(DESTDIR)$(MANDIR)/man1/aerc-search.1
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-accounts.5
diff --git a/contrib/carddav-query b/contrib/carddav-query
new file mode 100755
index 00000000..f7eaa793
--- /dev/null
+++ b/contrib/carddav-query
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Copyright (c) 2023 Robin Jarry
+
+"""
+Query a CardDAV server for contact names and emails.
+"""
+
+import argparse
+import base64
+import configparser
+import os
+import re
+import subprocess
+import sys
+import xml.etree.ElementTree as xml
+from urllib import error, parse, request
+
+
+def main():
+ try:
+ args = parse_args()
+
+ C = "urn:ietf:params:xml:ns:carddav"
+ D = "DAV:"
+ xml.register_namespace("C", C)
+ xml.register_namespace("D", D)
+
+ # perform the actual address book query
+ query = xml.Element(f"{{{C}}}addressbook-query")
+ prop = xml.SubElement(query, f"{{{D}}}prop")
+ xml.SubElement(prop, f"{{{D}}}getetag")
+ data = xml.SubElement(prop, f"{{{C}}}address-data")
+ xml.SubElement(data, f"{{{C}}}prop", name="FN")
+ xml.SubElement(data, f"{{{C}}}prop", name="EMAIL")
+ limit = xml.SubElement(query, f"{{{C}}}limit")
+ xml.SubElement(limit, f"{{{C}}}nresults").text = str(args.limit)
+ filtre = xml.SubElement(query, f"{{{C}}}filter", test="anyof")
+ for term in args.terms:
+ for attr in "FN", "EMAIL", "NICKNAME", "ORG", "TITLE":
+ prop = xml.SubElement(filtre, f"{{{C}}}prop-filter", name=attr)
+ match = xml.SubElement(
+ prop, f"{{{C}}}text-match", {"match-type": "contains"}
+ )
+ match.text = term
+ data = http_request_xml(
+ "REPORT",
+ args.server_url,
+ query,
+ username=args.username,
+ password=args.password,
+ debug=args.verbose,
+ Depth="1",
+ )
+ for vcard in data.iterfind(f".//{{{C}}}address-data"):
+ for name, email in parse_vcard(vcard.text.strip()):
+ print(f"{email}\t{name}")
+
+ except Exception as e:
+ if isinstance(e, error.HTTPError):
+ if args.verbose:
+ debug_response(e.fp)
+ e = e.fp.read().decode()
+ print(f"error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+def http_request_xml(
+ method: str,
+ url: str,
+ data: xml.Element,
+ username: str = None,
+ password: str = None,
+ debug: bool = False,
+ **headers,
+) -> xml.Element:
+ req = request.Request(
+ url=url,
+ method=method,
+ headers={
+ "Content-Type": 'text/xml; charset="utf-8"',
+ **headers,
+ },
+ data=xml.tostring(data, encoding="utf-8", xml_declaration=True),
+ )
+ if username is not None and password is not None:
+ auth = f"{username}:{password}"
+ auth = base64.standard_b64encode(auth.encode("utf-8")).decode("ascii")
+ req.add_header("Authorization", f"Basic {auth}")
+
+ if debug:
+ uri = parse.urlparse(req.full_url)
+ print(f"> {req.method} {uri.path} HTTP/1.1", file=sys.stderr)
+ print(f"> Host: {uri.hostname}", file=sys.stderr)
+ for name, value in req.headers.items():
+ print(f"> {name}: {value}", file=sys.stderr)
+ print(f"{req.data.decode('utf-8')}\n", file=sys.stderr)
+
+ with request.urlopen(req) as resp:
+ data = resp.read().decode("utf-8")
+ if debug:
+ debug_response(resp)
+ print(f"{data}", file=sys.stderr)
+
+ return xml.fromstring(data)
+
+
+def debug_response(resp):
+ print(f"< HTTP/1.1 {resp.code}", file=sys.stderr)
+ for name, value in resp.headers.items():
+ print(f"< {name}: {value}", file=sys.stderr)
+
+
+def parse_vcard(txt):
+ lines = txt.splitlines()
+ if len(lines) < 4 or lines[0] != "BEGIN:VCARD" or lines[-1] != "END:VCARD":
+ return
+ name = None
+ emails = []
+ for line in lines[1:-1]:
+ if line.startswith("FN:"):
+ name = line[len("FN:") :].replace("\\,", ",")
+ continue
+ match = re.match(r"^(?:ITEM\d+\.)?EMAIL(?:;[\w-]+=[^;:]+)*:(.+@.+)$", line)
+ if match:
+ email = match.group(1).lower().replace("\\,", ",")
+ if email not in emails:
+ if "TYPE=pref" in line or "PREF=1" in line:
+ emails.insert(0, email)
+ else:
+ emails.append(email)
+ if name is not None:
+ for e in emails:
+ yield name, e
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "-l",
+ "--limit",
+ default=10,
+ type=int,
+ help="""
+ Maximum number of results returned by the server (default: 10).
+ If the server does not support limiting, this will be disregarded.
+ """,
+ )
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="""
+ Print debug info on stderr.
+ """,
+ )
+ parser.add_argument(
+ "-c",
+ "--config-file",
+ metavar="FILE",
+ default=os.path.expanduser("~/.config/aerc/accounts.conf"),
+ help="""
+ INI configuration file from which to read the CardDAV URL endpoint
+ (default: ~/.config/aerc/accounts.conf).
+ """,
+ )
+ parser.add_argument(
+ "-S",
+ "--config-section",
+ metavar="SECTION",
+ help="""
+ INI configuration section where to find CONFIG_KEY. By default the
+ first section where CONFIG_KEY is found will be used.
+ """,
+ )
+ parser.add_argument(
+ "-k",
+ "--config-key-source",
+ metavar="KEY_SOURCE",
+ default="carddav-source",
+ help="""
+ INI configuration key to lookup in CONFIG_SECTION from CONFIG_FILE.
+ The value must respect the following format:
+ https?://USERNAME[:PASSWORD]@HOSTNAME/PATH/TO/ADDRESSBOOK.
+ Both USERNAME and PASSWORD must be percent encoded.
+ """,
+ )
+ parser.add_argument(
+ "-C",
+ "--config-key-cred-cmd",
+ metavar="KEY_CRED_CMD",
+ default="carddav-source-cred-cmd",
+ help="""
+ INI configuration key to lookup in CONFIG_SECTION from CONFIG_FILE. The
+ value is a command that will be used to determine PASSWORD if it is not
+ present in CONFIG_KEY_SOURCE.
+ """,
+ )
+ parser.add_argument(
+ "-s",
+ "--server-url",
+ help="""
+ CardDAV server URL endpoint. Overrides configuration file.
+ """,
+ )
+ parser.add_argument(
+ "-u",
+ "--username",
+ help="""
+ Username to authenticate on the server. Overrides configuration file.
+ """,
+ )
+ parser.add_argument(
+ "-p",
+ "--password",
+ help="""
+ Password for the specified user. Overrides configuration file.
+ """,
+ )
+ parser.add_argument(
+ "terms",
+ nargs="+",
+ metavar="TERM",
+ help="""
+ Search term. Will be used to search contacts from their FN (formatted
+ name), EMAIL, NICKNAME, ORG (company) and TITLE fields.
+ """,
+ )
+ args = parser.parse_args()
+
+ cfg = configparser.RawConfigParser(strict=False)
+ cfg.read([args.config_file])
+ source = cred_cmd = None
+ if args.config_section:
+ source = cfg.get(args.config_section, args.config_key_source, fallback=None)
+ cred_cmd = cfg.get(args.config_section, args.config_key_cred_cmd, fallback=None)
+ else:
+ for sec in cfg.sections():
+ source = cfg.get(sec, args.config_key_source, fallback=None)
+ if source is not None:
+ cred_cmd = cfg.get(sec, args.config_key_cred_cmd, fallback=None)
+ break
+ if source is not None:
+ try:
+ u = parse.urlparse(source)
+ if args.username is None:
+ args.username = u.username
+ if args.password is None:
+ args.password = u.password
+ if not args.password and cred_cmd is not None:
+ args.password = subprocess.check_output(
+ cred_cmd, shell=True, text=True, encoding="utf-8"
+ ).strip()
+ if args.server_url is None:
+ args.server_url = f"{u.scheme}://{u.hostname}"
+ if u.port is not None:
+ args.server_url += f":{u.port}"
+ args.server_url += u.path
+ except ValueError as e:
+ parser.error(f"{args.config_file}: {e}")
+ if args.server_url is None:
+ parser.error("SERVER_URL is required")
+
+ return args
+
+
+if __name__ == "__main__":
+ main()
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index f092bfcf..af989045 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -550,7 +550,10 @@ These options are configured in the *[compose]* section of _aerc.conf_.
This parameter can also be set per account in _accounts.conf_.
- Example:
+ Example with *carddav-query*(1):
+ *address-book-cmd* = _carddav-query %s_
+
+ Example with *khard*(1):
*address-book-cmd* = _khard email --remove-first-line --parsable %s_
*file-picker-cmd* = _<command>_
@@ -923,7 +926,7 @@ These options are configured in the *[templates]* section of _aerc.conf_.
*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-maildir*(5)
*aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5) *aerc-smtp*(5)
-*aerc-stylesets*(7)
+*aerc-stylesets*(7) *carddav-query*(1)
# AUTHORS
diff --git a/doc/carddav-query.1.scd b/doc/carddav-query.1.scd
new file mode 100644
index 00000000..bdd708ab
--- /dev/null
+++ b/doc/carddav-query.1.scd
@@ -0,0 +1,103 @@
+CARDDAV-QUERY(1)
+
+# NAME
+
+carddav-query - Query a CardDAV server for contact names and emails.
+
+# SYNOPSIS
+
+*carddav-query* [*-h*] [*-l* _<limit>_] [*-v*] [*-c* _<file>_]
+\[*-s* _<section>_] [*-k* _<key\_source>_] [*-C* _<key\_cred\_cmd>_]
+\[*-s* _<server\_url>_] [*-u* _<username>_] [*-p* _<password>_] _<term>_ [_<term>_ ...]
+
+This tool has been tailored for use as *address-book-cmd* in *aerc-config*(5).
+
+# OPTIONS
+
+*-h*, *--help*
+ show this help message and exit
+
+*-v*, *--verbose*
+ Print debug info on stderr.
+
+*-l* _<limit>_, *--limit* _<limit>_
+ Maximum number of results returned by the server. If the server does not
+ support limiting, this option will be disregarded.
+
+ Default: _10_
+
+*-c* _<file>_, *--config-file* _<file>_
+ INI configuration file from which to read the CardDAV URL endpoint.
+
+ Default: _~/.config/aerc/accounts.conf_
+
+*-S* _<section>_, *--config-section* _<section>_
+ INI configuration section where to find _<key\_source>_ and
+ _<key\_cred\_cmd>_. By default the first section where _<key\_source>_
+ is found will be used.
+
+*-k* _<key\_source>_, *--config-key-source* _<key\_source>_
+ INI configuration key to lookup in _<section>_ from _<file>_. The value
+ must respect the following format:
+
+ https?://_<username>_[:_<password>_]@_<hostname>_/_<path/to/addressbook>_
+
+ Both _<username>_ and _<password>_ must be percent encoded. If
+ _<password>_ is omitted, it can be provided via *--config-key-cred-cmd*
+ or *--password*.
+
+ Default: _carddav-source_
+
+*-C* _<key\_cred\_cmd>_, *--config-key-cred-cmd* _<key\_cred\_cmd>_
+ INI configuration key to lookup in _<section>_ from _<file>_. The value
+ is a command that will be executed with *sh -c* to determine
+ _<password>_ if it is not present in _<key\_source>_.
+
+ Default: _carddav-source-cred-cmd_
+
+*-s* _<server_url>_, *--server-url* _<server_url>_
+ CardDAV server URL endpoint. Overrides configuration file.
+
+*-u* _<username>_, *--username* _<username>_
+ Username to authenticate on the server. Overrides configuration file.
+
+*-p* _<password>_, *--password* _<password>_
+ Password for the specified user. Overrides configuration file.
+
+# POSITIONAL ARGUMENTS
+
+_<term>_
+ Search term. Will be used to search contacts from their FN (formatted
+ name), EMAIL, NICKNAME, ORG (company) and TITLE fields.
+
+# EXAMPLES
+
+These are excerpts of _~/.config/aerc/accounts.conf_.
+
+## Fastmail
+
+```
+[fastmail]
+carddav-source = https://janedoe%40fastmail.com@carddav.fastmail.com/dav/addressbooks/user/janedoe@fastmail.com/Default
+carddav-source-cred-cmd = pass fastmail.com/janedoe
+address-book-cmd = carddav-query -S fastmail %s
+```
+
+## Gmail
+
+```
+[gmail]
+carddav-source = https://johndoe%40gmail.com@www.googleapis.com/carddav/v1/principals/johndoe@gmail.com/lists/default
+carddav-source-cred-cmd = pass gmail.com/johndoe
+address-book-cmd = carddav-query -S gmail %s
+```
+
+# SEE ALSO
+
+*aerc-config*(5)
+
+# AUTHORS
+
+Created by Robin Jarry <robin@jarry.cc> who is assisted by other open source
+contributors. For more information about aerc development, see
+https://sr.ht/~rjarry/aerc/.