greenhouse

greenhouse

minimal software forge

git clone git@git.lostduk.com:greenhouse.git

feat(herbarium): init

commit43b6fb5aa340705bc135211e7cd71adc8e156cf2
parent567b11fe538ef86b976472dad4afadc42830449c
authorlostduk <hi@lostduk.com>
date2026-10-17 23:14 +0700
.gitignore3+
herbarium/Makefile36+
herbarium/config.def.h17+
herbarium/config.mk7+
herbarium/herbarium.c2001++++++++++++++++++++++++++++++++++++++++
herbarium/icon.svg26+
herbarium/style.css432+++++++++
pollen/pollen.12+
pollen/pollen.c9+

9 files changed, -5/+2528

.gitignore

@@ -1,7 +1,8 @@ .dev/ test.sh-*/.o+*/*.o */config.h */hedge */petal */pollen+*/herbarium

herbarium/Makefile

@@ -0,0 +1,36 @@+include config.mk++SRC = herbarium.c+OBJ = ${SRC:.c=.o}++all: herbarium++.c.o:+	${CC} -c ${CFLAGS} $<++${OBJ}: config.h config.mk++config.h:+	cp config.def.h $@++herbarium: ${OBJ}+	${CC} -o $@ ${OBJ} ${LDFLAGS}++clean:+	rm -f herbarium ${OBJ}++install: all+	mkdir -p ${DESTDIR}${PREFIX}/bin+	cp -f herbarium ${DESTDIR}${PREFIX}/bin+	chmod 755 ${DESTDIR}${PREFIX}/bin/herbarium+	mkdir -p ${DESTDIR}${PREFIX}/share/herbarium+	cp -f style.css icon.svg ${DESTDIR}${PREFIX}/share/herbarium+	chmod 644 ${DESTDIR}${PREFIX}/share/herbarium/style.css\+		${DESTDIR}${PREFIX}/share/herbarium/icon.svg++uninstall:+	rm -f ${DESTDIR}${PREFIX}/bin/herbarium\+		${DESTDIR}${PREFIX}/share/herbarium/style.css\+		${DESTDIR}${PREFIX}/share/herbarium/icon.svg++.PHONY: all clean install uninstall

herbarium/config.def.h

@@ -0,0 +1,17 @@+static const char *root    = "/srv/lists";+static const char *domain  = "lists.lostduk.com";+static const char *css     = "/style.css";+static const char *title   = "greenhouse";+static const char *icon    = "/icon.svg";+static const char *service = "lists";+static const int perpage   = 50;+static const int maxmsgs   = 20000;+static const int maxhdr    = 64 * 1024;+static const int maxbody   = 512 * 1024;+static const int maxdepth  = 8;+static const int maxcpu    = 10;++static const Service services[] = {+	{ "git",   "https://git.lostduk.com/" },+	{ "lists", "https://lists.lostduk.com/" },+};

herbarium/config.mk

@@ -0,0 +1,7 @@+PREFIX = /usr/local++CPPFLAGS = -D_XOPEN_SOURCE=700+CFLAGS   = -std=c99 -pedantic -Wall -Wextra -Os ${CPPFLAGS}+LDFLAGS  =++CC = cc

herbarium/herbarium.c

@@ -0,0 +1,2001 @@+/* imports */+#include <ctype.h>+#include <dirent.h>+#include <errno.h>+#include <fcntl.h>+#include <iconv.h>+#include <limits.h>+#include <stdarg.h>+#include <stdio.h>+#include <stdlib.h>+#include <string.h>+#include <strings.h>+#include <time.h>+#include <unistd.h>+#include <sys/resource.h>+#include <sys/stat.h>++/* macros */+#define LENGTH(X) (sizeof(X) / sizeof((X)[0]))+#define HOUR      3600L+#define DAY       (24 * HOUR)+#define MAXTIME   253402300799LL+#define FIELDLEN  998+#define MAXREFS   16+#define NONE      ((size_t)-1)+#define REPL      "\xef\xbf\xbd"++/* enums */+typedef struct {+	char *s;+	size_t n, cap;+} Buf;++typedef struct {+	char *file;+	char *id;+	char *irt;+	char *refs[MAXREFS];+	size_t nrefs;+	char *subject;+	char *name;+	char *addr;+	long long mtime;+	long long date;+	int tz;+	size_t parent;+	size_t root;+	size_t seen;+} Msg;++typedef struct {+	const char *body;+	size_t len;+	char type[64];+	char cte[32];+	char charset[64];+	char boundary[128];+	char name[512];+} Part;++typedef struct {+	const char *name;+	const char *url;+} Service;++typedef struct {+	size_t root;+	size_t n;+	long long last;+} Thread;++/* function declarations */+static void attachment(const Part *p);+static void b64(const char *s, size_t n, Buf *out);+static const char *boundary(const char *s, const char *end, const char *b,+                            size_t blen);+static int bprintf(char *buf, size_t len, const char *fmt, ...);+static void bufadd(Buf *b, const char *s, size_t n);+static void bufc(Buf *b, int c);+static size_t byid(const char *id);+static char *clean(const char *s, size_t n);+static int cmpdate(const void *a, const void *b);+static int cmpfirst(const void *a, const void *b);+static int cmpid(const void *a, const void *b);+static int cmpmtime(const void *a, const void *b);+static int cmpthread(const void *a, const void *b);+static void decode(const Part *p, Buf *out);+static void decode2047(const char *s, Buf *out);+static void die(const char *msg);+static size_t digits(const char **s, size_t max, long *v);+static const char *encword(const char *p, Buf *out);+static const char *envor(const char *name, const char *fallback);+static char *field(const char *v);+static void freemsg(Msg *m);+static void freemsgs(void);+static size_t hdrend(const char *s, size_t n, size_t *body);+static char *hdrget(const char *h, size_t n, const char *name);+static int hex(int c);+static void httpheader(int status, const char *type);+static void indexpage(void);+static void indexrow(const char *name);+static int islist(const char *name);+static int isstat(const char *s, const char *end);+static const char *lineclass(const char *l, size_t len, const char *next,+                             const char *end, int *patch);+static void listheader(int addrs);+static void listpage(void);+static int loadmsg(Msg *m);+static void mboxpage(size_t t);+static void msgurl(const char *id, const char *page);+static void multipart(const Part *p, int depth, size_t m);+static char *nextid(const char **sp);+static void notfound(void);+static FILE *openmsg(const char *file);+static void pagebegin(int status, const char *pagetitle);+static void pageend(void);+static int param(const char *v, const char *attr, char *buf, size_t len);+static int parsedate(const char *s, long long *t, int *tz);+static void parsefrom(const char *v, Msg *m);+static void parsepart(const char *s, size_t n, Part *p);+static void printage(long long t);+static void printbody(const char *s, size_t n);+static void printmsg(size_t i, int sel);+static void printtime(long long t, int tz, const char *fmt);+static void qp(const char *s, size_t n, int q, Buf *out);+static void rawpage(size_t t);+static int readfile(const char *file, size_t max, Buf *b, int hdronly);+static void readline(const char *name, const char *file, char *buf,+                     size_t len);+static void renderpart(const char *s, size_t n, int depth, size_t m);+static void route(char *path);+static void sanitize(const char *s, size_t n, Buf *out, int multi);+static void scan(const char *name);+static const char *skipcfws(const char *s);+static void threadmsgs(void);+static void threadpage(size_t t);+static int tou8(const char *cs, const char *s, size_t n, Buf *out);+static void urlencode(const char *s);+static int validid(const char *s, size_t n);+static int validlist(const char *s);+static void xmlencode(const char *s);+static void xmlencoden(const char *s, size_t len);+static char *xstrndup(const char *s, size_t n);++/* variables */+static const char *base;+static const char *list;+static Msg *msgs;+static size_t nmsgs;+static size_t *ids;+static size_t left;+static int cut;+static long ofs;++#include "config.h"++/* function implementations */+void+attachment(const Part *p)+{+	char *name;+	size_t i, n = p->len;++	if (!strcmp(p->cte, "base64")) {+		for (i = n = 0; i < p->len; i++)+			if (isalnum((unsigned char)p->body[i])+			|| p->body[i] == '+' || p->body[i] == '/')+				n++;+		n = n * 3 / 4;+	}+	name = field(*p->name ? p->name : NULL);+	if (strlen(name) + 64 > left) {+		cut = 1;+		free(name);+		return;+	}+	left -= strlen(name) + 64;+	fputs("<p class=\"att\">attachment: ", stdout);+	xmlencode(*name ? name : "(unnamed)");+	fputs(" (", stdout);+	xmlencode(p->type);+	printf(", %zuB)</p>\n", n);+	free(name);+}++void+b64(const char *s, size_t n, Buf *out)+{+	unsigned long v = 0;+	size_t i;+	int c, bits = 0;++	for (i = 0; i < n; i++) {+		c = (unsigned char)s[i];+		if (c >= 'A' && c <= 'Z')+			c -= 'A';+		else if (c >= 'a' && c <= 'z')+			c -= 'a' - 26;+		else if (c >= '0' && c <= '9')+			c -= '0' - 52;+		else if (c == '+')+			c = 62;+		else if (c == '/')+			c = 63;+		else {+			if (c == '=')+				bits = 0;+			continue;+		}+		v = (v << 6 | (unsigned long)c) & 0xffffff;+		if ((bits += 6) >= 8) {+			bits -= 8;+			bufc(out, (int)(v >> bits & 0xff));+		}+	}+}++const char *+boundary(const char *s, const char *end, const char *b, size_t blen)+{+	const char *p;++	while (s < end) {+		if ((size_t)(end - s) >= blen + 2 && s[0] == '-' && s[1] == '-'+		&& !memcmp(s + 2, b, blen)+		&& (s + blen + 2 == end+		|| (s[blen + 2] && strchr("\r\n \t-", s[blen + 2]))))+			return s;+		if (!(p = memchr(s, '\n', end - s)))+			break;+		s = p + 1;+	}+	return NULL;+}++int+bprintf(char *buf, size_t len, const char *fmt, ...)+{+	va_list ap;+	int n;++	va_start(ap, fmt);+	n = vsnprintf(buf, len, fmt, ap);+	va_end(ap);+	if (n < 0 || (size_t)n >= len)+		return -1;+	return 0;+}++void+bufadd(Buf *b, const char *s, size_t n)+{+	size_t cap;+	char *p;++	if (b->n + n + 1 > b->cap) {+		for (cap = b->cap ? b->cap : 256; cap < b->n + n + 1; cap *= 2)+			;+		if (!(p = realloc(b->s, cap)))+			die("out of memory");+		b->s = p;+		b->cap = cap;+	}+	if (n)+		memcpy(b->s + b->n, s, n);+	b->n += n;+	b->s[b->n] = '\0';+}++void+bufc(Buf *b, int c)+{+	char ch = (char)c;++	bufadd(b, &ch, 1);+}++size_t+byid(const char *id)+{+	size_t lo = 0, hi = nmsgs, mid;++	while (lo < hi) {+		mid = lo + (hi - lo) / 2;+		if (strcmp(msgs[ids[mid]].id, id) < 0)+			lo = mid + 1;+		else+			hi = mid;+	}+	if (lo < nmsgs && !strcmp(msgs[ids[lo]].id, id))+		return ids[lo];+	return NONE;+}++char *+clean(const char *s, size_t n)+{+	Buf b = { 0 };+	char *r;+	size_t i;++	sanitize(s, n, &b, 0);+	bufadd(&b, "", 0);+	for (i = 0; b.s[i] == ' '; i++)+		;+	for (n = b.n; n > i && b.s[n - 1] == ' '; n--)+		;+	if (n - i > FIELDLEN) {+		for (n = i + FIELDLEN; n > i+		     && ((unsigned char)b.s[n] & 0xc0) == 0x80; n--)+			;+	}+	r = xstrndup(b.s + i, n - i);+	free(b.s);+	return r;+}++int+cmpdate(const void *a, const void *b)+{+	const Msg *x = a, *y = b;++	if (x->date != y->date)+		return x->date < y->date ? -1 : 1;+	if (x->mtime != y->mtime)+		return x->mtime < y->mtime ? -1 : 1;+	return strcmp(x->file, y->file);+}++int+cmpfirst(const void *a, const void *b)+{+	const Msg *x = a, *y = b;+	int c;++	if ((c = strcmp(x->id, y->id)))+		return c;+	if (x->mtime != y->mtime)+		return x->mtime < y->mtime ? -1 : 1;+	return strcmp(x->file, y->file);+}++int+cmpid(const void *a, const void *b)+{+	size_t x = *(const size_t *)a, y = *(const size_t *)b;+	int c;++	if ((c = strcmp(msgs[x].id, msgs[y].id)))+		return c;+	return x < y ? -1 : x > y;+}++int+cmpmtime(const void *a, const void *b)+{+	const Msg *x = a, *y = b;++	if (x->mtime != y->mtime)+		return x->mtime > y->mtime ? -1 : 1;+	return strcmp(x->file, y->file);+}++int+cmpthread(const void *a, const void *b)+{+	const Thread *x = a, *y = b;++	if (x->last != y->last)+		return x->last > y->last ? -1 : 1;+	return strcmp(msgs[x->root].id, msgs[y->root].id);+}++void+decode(const Part *p, Buf *out)+{+	if (!strcmp(p->cte, "base64"))+		b64(p->body, p->len, out);+	else if (!strcmp(p->cte, "quoted-printable"))+		qp(p->body, p->len, 0, out);+	else+		bufadd(out, p->body, p->len);+}++void+decode2047(const char *s, Buf *out)+{+	const char *q, *r;+	int enc = 0;++	while (*s) {+		if (s[0] == '=' && s[1] == '?' && (q = encword(s, out))) {+			s = q;+			enc = 1;+			continue;+		}+		if (enc && isspace((unsigned char)*s)) {+			for (q = s; isspace((unsigned char)*q); q++)+				;+			if (q[0] == '=' && q[1] == '?'+			&& (r = encword(q, out))) {+				s = r;+				continue;+			}+			bufadd(out, s, q - s);+			s = q;+			enc = 0;+			continue;+		}+		bufc(out, *s++);+		enc = 0;+	}+}++void+die(const char *msg)+{+	fprintf(stderr, "herbarium: %s\n", msg);+	exit(1);+}++size_t+digits(const char **s, size_t max, long *v)+{+	size_t n;++	*v = 0;+	for (n = 0; isdigit((unsigned char)(*s)[n]); n++)+		if (n < max)+			*v = *v * 10 + (*s)[n] - '0';+	*s += n;+	return n > max ? 0 : n;+}++const char *+encword(const char *p, Buf *out)+{+	Buf raw = { 0 };+	char cs[64];+	const char *q, *e;+	size_t n;+	int enc, r;++	q = p + 2;+	for (n = 0; q[n] != '?'; n++)+		if (!q[n] || n + 1 >= sizeof(cs)+		|| isspace((unsigned char)q[n]))+			return NULL;+	memcpy(cs, q, n);+	cs[n] = '\0';+	q += n;+	enc = tolower((unsigned char)q[1]);+	if ((enc != 'b' && enc != 'q') || q[2] != '?')+		return NULL;+	q += 3;+	for (e = q; !(e[0] == '?' && e[1] == '='); e++)+		if (!*e || isspace((unsigned char)*e))+			return NULL;+	if (enc == 'b')+		b64(q, e - q, &raw);+	else+		qp(q, e - q, 1, &raw);+	r = tou8(cs, raw.s, raw.n, out);+	free(raw.s);+	return r < 0 ? NULL : e + 2;+}++const char *+envor(const char *name, const char *fallback)+{+	const char *v;++	v = getenv(name);+	return v && *v ? v : fallback;+}++char *+field(const char *v)+{+	Buf b = { 0 };+	char *s, *r;+	size_t n;++	if (!v)+		return xstrndup("", 0);+	n = strlen(v);+	s = xstrndup(v, n > 4 * FIELDLEN ? 4 * FIELDLEN : n);+	decode2047(s, &b);+	r = clean(b.s, b.n);+	free(s);+	free(b.s);+	return r;+}++void+freemsg(Msg *m)+{+	size_t i;++	free(m->file);+	free(m->id);+	free(m->irt);+	for (i = 0; i < m->nrefs; i++)+		free(m->refs[i]);+	free(m->subject);+	free(m->name);+	free(m->addr);+}++void+freemsgs(void)+{+	size_t i;++	for (i = 0; i < nmsgs; i++)+		freemsg(&msgs[i]);+	free(msgs);+	free(ids);+	msgs = NULL;+	ids = NULL;+	nmsgs = 0;+}++size_t+hdrend(const char *s, size_t n, size_t *body)+{+	const char *p;+	size_t pos = 0;++	while (pos < n) {+		if (s[pos] == '\n') {+			*body = pos + 1;+			return pos;+		}+		if (s[pos] == '\r' && pos + 1 < n && s[pos + 1] == '\n') {+			*body = pos + 2;+			return pos;+		}+		if (!(p = memchr(s + pos, '\n', n - pos)))+			break;+		pos = p - s + 1;+	}+	*body = n;+	return n;+}++char *+hdrget(const char *h, size_t n, const char *name)+{+	Buf b = { 0 };+	const char *p;+	size_t pos, k, len = strlen(name);+	char *r;++	for (pos = 0; pos < n; pos = p ? (size_t)(p - h) + 1 : n) {+		p = memchr(h + pos, '\n', n - pos);+		if (n - pos <= len || strncasecmp(h + pos, name, len))+			continue;+		for (k = pos + len; k < n && (h[k] == ' ' || h[k] == '\t');+		     k++)+			;+		if (k >= n || h[k] != ':')+			continue;+		bufadd(&b, "", 0);+		for (k++; k < n; k++) {+			if (h[k] == '\n') {+				if (k + 1 >= n || (h[k + 1] != ' '+				&& h[k + 1] != '\t'))+					break;+			} else if (h[k] != '\r' && h[k]) {+				bufc(&b, h[k]);+			}+		}+		for (k = 0; isspace((unsigned char)b.s[k]); k++)+			;+		while (b.n > k && isspace((unsigned char)b.s[b.n - 1]))+			b.n--;+		r = xstrndup(b.s + k, b.n - k);+		free(b.s);+		return r;+	}+	return NULL;+}++int+hex(int c)+{+	if (c >= '0' && c <= '9')+		return c - '0';+	if (c >= 'a' && c <= 'f')+		return c - 'a' + 10;+	if (c >= 'A' && c <= 'F')+		return c - 'A' + 10;+	return -1;+}++void+httpheader(int status, const char *type)+{+	if (status == 404)+		fputs("Status: 404 Not Found\r\n", stdout);+	printf("Content-Type: %s\r\n", type);+	fputs("Content-Security-Policy: default-src 'none'; "+		"style-src 'self'; img-src 'self'\r\n"+		"X-Content-Type-Options: nosniff\r\n\r\n", stdout);+}++void+indexpage(void)+{+	struct dirent **ents;+	int i, n;++	pagebegin(200, title);+	fputs("<main>\n<table class=\"lists\">\n<thead><tr><th>Name</th>"+		"<th class=\"sm\">Description</th>"+		"<th class=\"sm\">Address</th>"+		"<th class=\"r\">Threads</th><th class=\"r\">Updated</th>"+		"</tr></thead>\n<tbody>\n", stdout);+	if ((n = scandir(root, &ents, NULL, alphasort)) >= 0) {+		for (i = 0; i < n; i++) {+			if (islist(ents[i]->d_name))+				indexrow(ents[i]->d_name);+			free(ents[i]);+		}+		free(ents);+	}+	fputs("</tbody>\n</table>\n", stdout);+	pageend();+}++void+indexrow(const char *name)+{+	char desc[256];+	long long last = -1;+	size_t i, threads = 0;++	list = name;+	scan(name);+	for (i = 0; i < nmsgs; i++) {+		if (msgs[i].root == i)+			threads++;+		if (msgs[i].date > last)+			last = msgs[i].date;+	}+	readline(name, "description", desc, sizeof(desc));+	fputs("<tr><td class=\"name\"><a href=\"", stdout);+	xmlencode(base);+	putchar('/');+	urlencode(name);+	fputs("/\">", stdout);+	xmlencode(name);+	fputs("</a></td><td class=\"desc sm\">", stdout);+	xmlencode(desc);+	fputs("</td><td class=\"sm\"><a href=\"mailto:", stdout);+	urlencode(name);+	putchar('@');+	xmlencode(domain);+	fputs("\">", stdout);+	xmlencode(name);+	putchar('@');+	xmlencode(domain);+	printf("</a></td><td class=\"r num\">%zu</td>", threads);+	printage(last);+	fputs("</tr>\n", stdout);+	freemsgs();+}++int+islist(const char *name)+{+	char path[PATH_MAX];+	struct stat st;++	return validlist(name)+	    && bprintf(path, sizeof(path), "%s/%s", root, name) == 0+	    && stat(path, &st) == 0 && S_ISDIR(st.st_mode)+	    && bprintf(path, sizeof(path), "%s/%s/public", root, name) == 0+	    && stat(path, &st) == 0;+}++int+isstat(const char *s, const char *end)+{+	const char *nl;+	size_t i, len;++	nl = memchr(s, '\n', end - s);+	len = nl ? (size_t)(nl - s) : (size_t)(end - s);+	if (!len || *s != ' ')+		return 0;+	for (i = 0; i + 3 <= len; i++)+		if (!memcmp(s + i, " | ", 3))+			return 1;+	return 0;+}++const char *+lineclass(const char *l, size_t len, const char *next, const char *end,+          int *patch)+{+	if (len >= 11 && !memcmp(l, "diff --git ", 11)) {+		*patch = 1;+		return "f";+	}+	if (!*patch) {+		if (len == 3 && !memcmp(l, "---", 3) && next+		&& isstat(next, end)) {+			*patch = 1;+			return "f";+		}+		return len && *l == '>' ? "q" : NULL;+	}+	if (len == 3 && !memcmp(l, "-- ", 3)) {+		*patch = 0;+		return NULL;+	}+	if ((len == 3 && !memcmp(l, "---", 3))+	|| (len >= 4 && (!memcmp(l, "--- ", 4) || !memcmp(l, "+++ ", 4))))+		return "f";+	if (len >= 2 && !memcmp(l, "@@", 2))+		return "h";+	if (len && *l == '+')+		return "i";+	if (len && *l == '-')+		return "d";+	if (len && isalpha((unsigned char)*l))+		return "f";+	return NULL;+}++void+listheader(int addrs)+{+	static const char *cmds[] = { "", "subscribe", "unsubscribe", "help" };+	char desc[256];+	size_t i;++	readline(list, "description", desc, sizeof(desc));+	fputs("<header>\n<h1><a href=\"", stdout);+	xmlencode(base);+	putchar('/');+	urlencode(list);+	fputs("/\">", stdout);+	xmlencode(list);+	fputs("</a></h1>\n", stdout);+	if (*desc) {+		fputs("<p class=\"desc\">", stdout);+		xmlencode(desc);+		fputs("</p>\n", stdout);+	}+	if (addrs) {+		fputs("<table class=\"addrs\">\n", stdout);+		for (i = 0; i < LENGTH(cmds); i++) {+			printf("<tr><td>%s</td><td><a href=\"mailto:", *cmds[i]+				? cmds[i] : "post");+			urlencode(list);+			printf("%s%s@", *cmds[i] ? "+" : "", cmds[i]);+			xmlencode(domain);+			fputs("\">", stdout);+			xmlencode(list);+			printf("%s%s@", *cmds[i] ? "+" : "", cmds[i]);+			xmlencode(domain);+			fputs("</a></td></tr>\n", stdout);+		}+		fputs("</table>\n", stdout);+	}+	fputs("</header>\n<main>\n", stdout);+}++void+listpage(void)+{+	Thread *t;+	const Msg *m;+	size_t *slot, i, r, n = 0, shown = 0;++	scan(list);+	if (!(t = calloc(nmsgs + 1, sizeof(*t)))+	|| !(slot = calloc(nmsgs + 1, sizeof(*slot))))+		die("out of memory");+	for (i = 0; i < nmsgs; i++)+		slot[i] = NONE;+	for (i = 0; i < nmsgs; i++) {+		r = msgs[i].root;+		if (slot[r] == NONE) {+			slot[r] = n;+			t[n].root = r;+			t[n].last = msgs[i].date;+			n++;+		}+		t[slot[r]].n++;+		if (msgs[i].date > t[slot[r]].last)+			t[slot[r]].last = msgs[i].date;+	}+	qsort(t, n, sizeof(*t), cmpthread);++	pagebegin(200, list);+	listheader(1);+	fputs("<table class=\"threads\">\n<thead><tr><th>Subject</th>"+		"<th class=\"sm\">From</th><th class=\"r\">Msgs</th>"+		"<th class=\"r\">Last</th></tr></thead>\n<tbody>\n", stdout);+	for (i = (size_t)ofs; i < n && shown < (size_t)perpage; i++, shown++) {+		m = &msgs[t[i].root];+		fputs("<tr><td class=\"msg\"><a href=\"", stdout);+		msgurl(m->id, "");+		fputs("\">", stdout);+		xmlencode(*m->subject ? m->subject : "(no subject)");+		fputs("</a></td><td class=\"sm\">", stdout);+		xmlencode(*m->name ? m->name : m->addr);+		printf("</td><td class=\"r num\">%zu</td><td class=\"r date\" "+			"title=\"", t[i].n);+		printtime(t[i].last, 0, "%Y-%m-%d %H:%M UTC");+		fputs("\">", stdout);+		printtime(t[i].last, 0, "%Y-%m-%d");+		fputs("</td></tr>\n", stdout);+	}+	fputs("</tbody>\n</table>\n", stdout);+	if (!shown) {+		fputs("<p>No messages.</p>\n", stdout);+	} else if (ofs || i < n) {+		fputs("<nav class=\"pager\">", stdout);+		if (ofs) {+			fputs("<a href=\"", stdout);+			xmlencode(base);+			putchar('/');+			urlencode(list);+			putchar('/');+			if (ofs > perpage)+				printf("?ofs=%ld", ofs - perpage);+			fputs("\">&larr; newer</a>", stdout);+		}+		printf("<span>%ld&ndash;%ld</span>", ofs + 1,+			ofs + (long)shown);+		if (i < n) {+			fputs("<a href=\"", stdout);+			xmlencode(base);+			putchar('/');+			urlencode(list);+			printf("/?ofs=%zu\">older &rarr;</a>", i);+		}+		fputs("</nav>\n", stdout);+	}+	pageend();+	free(slot);+	free(t);+}++int+loadmsg(Msg *m)+{+	Buf b = { 0 };+	const char *p;+	char *v;+	size_t h, body;++	if (readfile(m->file, (size_t)maxhdr, &b, 1) < 0)+		return -1;+	h = hdrend(b.s, b.n, &body);+	if ((v = hdrget(b.s, h, "Message-ID"))) {+		p = v;+		if (!(m->id = nextid(&p)) && validid(v, strlen(v)))+			m->id = xstrndup(v, strlen(v));+		free(v);+	}+	if (!m->id) {+		free(b.s);+		return -1;+	}+	if ((v = hdrget(b.s, h, "In-Reply-To"))) {+		p = v;+		m->irt = nextid(&p);+		free(v);+	}+	if ((v = hdrget(b.s, h, "References"))) {+		for (p = v; *p;) {+			if (!(m->refs[m->nrefs] = nextid(&p)))+				break;+			if (++m->nrefs == MAXREFS) {+				free(m->refs[0]);+				memmove(m->refs, m->refs + 1,+					(MAXREFS - 1) * sizeof(*m->refs));+				m->nrefs--;+			}+		}+		free(v);+	}+	v = hdrget(b.s, h, "Subject");+	m->subject = field(v);+	free(v);+	v = hdrget(b.s, h, "From");+	parsefrom(v ? v : "", m);+	free(v);+	v = hdrget(b.s, h, "Date");+	if (!v || parsedate(v, &m->date, &m->tz) < 0+	|| m->date > m->mtime + DAY) {+		m->date = m->mtime;+		m->tz = 0;+	}+	free(v);+	free(b.s);+	return 0;+}++void+mboxpage(size_t t)+{+	char *line = NULL;+	size_t i, k, cap = 0, r = msgs[t].root;+	ssize_t len;+	FILE *fp;+	int last;++	httpheader(200, "application/mbox");+	for (i = 0; i < nmsgs; i++) {+		if (msgs[i].root != r || !(fp = openmsg(msgs[i].file)))+			continue;+		fputs("From mboxrd@z Thu Jan  1 00:00:00 1970\n", stdout);+		last = '\n';+		while ((len = getline(&line, &cap, fp)) > 0) {+			for (k = 0; k < (size_t)len && line[k] == '>'; k++)+				;+			if ((size_t)len - k >= 5+			&& !memcmp(line + k, "From ", 5))+				putchar('>');+			fwrite(line, 1, len, stdout);+			last = line[len - 1];+		}+		if (last != '\n')+			putchar('\n');+		putchar('\n');+		fclose(fp);+	}+	free(line);+}++void+msgurl(const char *id, const char *page)+{+	xmlencode(base);+	putchar('/');+	urlencode(list);+	putchar('/');+	urlencode(id);+	putchar('/');+	fputs(page, stdout);+}++void+multipart(const Part *p, int depth, size_t m)+{+	const char *s, *end, *next, *pick = NULL;+	size_t blen, len, plen = 0;+	int alt, rank, best = 0;+	Part sub;++	end = p->body + p->len;+	blen = strlen(p->boundary);+	alt = !strcmp(p->type, "multipart/alternative");+	for (s = boundary(p->body, end, p->boundary, blen); s && !cut;+	     s = next) {+		s += blen + 2;+		if (end - s >= 2 && s[0] == '-' && s[1] == '-')+			break;+		if (!(s = memchr(s, '\n', end - s)))+			break;+		s++;+		next = boundary(s, end, p->boundary, blen);+		len = (next ? next : end) - s;+		if (len && s[len - 1] == '\n')+			len--;+		if (len && s[len - 1] == '\r')+			len--;+		if (!alt) {+			renderpart(s, len, depth + 1, m);+			continue;+		}+		parsepart(s, len, &sub);+		if (!strcmp(sub.type, "text/plain"))+			rank = 3;+		else if (!strncmp(sub.type, "multipart/", 10))+			rank = 2;+		else+			rank = 1;+		if (rank > best || (rank == 1 && best == 1)) {+			best = rank;+			pick = s;+			plen = len;+		}+	}+	if (pick)+		renderpart(pick, plen, depth + 1, m);+}++char *+nextid(const char **sp)+{+	const char *a, *b;++	for (a = strchr(*sp, '<'); a; a = b) {+		if (!(b = strpbrk(a + 1, "<>")))+			break;+		if (*b == '<')+			continue;+		*sp = b + 1;+		if (validid(a + 1, b - a - 1))+			return xstrndup(a + 1, b - a - 1);+		b = strchr(b + 1, '<');+	}+	*sp += strlen(*sp);+	return NULL;+}++void+notfound(void)+{+	pagebegin(404, "Not found");+	fputs("<main>\n<p>Not found.</p>\n", stdout);+	pageend();+}++FILE *+openmsg(const char *file)+{+	struct stat st;+	FILE *fp;+	int fd;++	if ((fd = open(file, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)) < 0)+		return NULL;+	if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)+	|| !(fp = fdopen(fd, "r"))) {+		close(fd);+		return NULL;+	}+	return fp;+}++void+pagebegin(int status, const char *pagetitle)+{+	size_t i;++	httpheader(status, "text/html; charset=utf-8");+	fputs("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n"+		"<meta name=\"viewport\" "+		"content=\"width=device-width, initial-scale=1\">\n<title>",+		stdout);+	xmlencode(pagetitle);+	fputs("</title>\n<link rel=\"stylesheet\" href=\"", stdout);+	xmlencode(css);+	fputs("\">\n<link rel=\"icon\" href=\"", stdout);+	xmlencode(icon);+	fputs("\">\n</head>\n<body>\n<div id=\"top\"><a class=\"brand\" "+		"href=\"", stdout);+	xmlencode(base);+	fputs("/\"><img src=\"", stdout);+	xmlencode(icon);+	fputs("\" alt=\"\"> ", stdout);+	xmlencode(title);+	fputs("</a><nav>", stdout);+	for (i = 0; i < LENGTH(services); i++) {+		if (!strcmp(services[i].name, service)) {+			fputs("<a class=\"on\" href=\"", stdout);+			xmlencode(base);+			putchar('/');+		} else {+			fputs("<a href=\"", stdout);+			xmlencode(services[i].url);+		}+		fputs("\">", stdout);+		xmlencode(services[i].name);+		fputs("</a>", stdout);+	}+	fputs("</nav></div>\n", stdout);+}++void+pageend(void)+{+	fputs("</main>\n</body>\n</html>\n", stdout);+}++int+param(const char *v, const char *attr, char *buf, size_t len)+{+	Buf raw = { 0 }, d = { 0 }, u = { 0 };+	const Buf *src;+	const char *p = v, *q;+	char *a, *l;+	size_t n, alen = strlen(attr);+	int quote, ext, ret = -1;++	while (ret < 0 && *p) {+		for (quote = 0; *p && (quote || *p != ';'); p++) {+			if (quote && *p == '\\' && p[1])+				p++;+			else if (*p == '"')+				quote = !quote;+		}+		if (!*p)+			break;+		for (p++; isspace((unsigned char)*p); p++)+			;+		for (q = p; *q && *q != '=' && *q != ';'+		     && !isspace((unsigned char)*q); q++)+			;+		n = q - p;+		ext = n == alen + 1 && p[alen] == '*';+		if ((n != alen && !ext) || strncasecmp(p, attr, alen))+			continue;+		for (; isspace((unsigned char)*q); q++)+			;+		if (*q != '=')+			continue;+		for (q++; isspace((unsigned char)*q); q++)+			;+		bufadd(&raw, "", 0);+		if (*q == '"') {+			for (q++; *q && *q != '"'; q++) {+				if (*q == '\\' && q[1])+					q++;+				bufc(&raw, *q);+			}+		} else {+			for (; *q && *q != ';'+			     && !isspace((unsigned char)*q); q++)+				bufc(&raw, *q);+		}+		src = &raw;+		if (ext && (a = strchr(raw.s, '\''))+		&& (l = strchr(a + 1, '\''))) {+			for (q = l + 1; *q; q++) {+				if (*q == '%' && hex(q[1]) >= 0+				&& hex(q[2]) >= 0) {+					bufc(&d, hex(q[1]) << 4 | hex(q[2]));+					q += 2;+				} else {+					bufc(&d, *q);+				}+			}+			*a = '\0';+			if (tou8(raw.s, d.s, d.n, &u) < 0)+				bufadd(&u, d.s, d.n);+			bufadd(&u, "", 0);+			src = &u;+		}+		n = src->n < len - 1 ? src->n : len - 1;+		memcpy(buf, src->s, n);+		buf[n] = '\0';+		ret = 0;+	}+	free(raw.s);+	free(d.s);+	free(u.s);+	return ret;+}++int+parsedate(const char *s, long long *t, int *tz)+{+	static const char months[] = "janfebmaraprmayjunjulaugsepoctnovdec";+	static const int mdays[] = {+		31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31+	};+	static const struct {+		const char *name;+		int off;+	} zones[] = {+		{ "edt", -240 }, { "est", -300 }, { "cdt", -300 },+		{ "cst", -360 }, { "mdt", -360 }, { "mst", -420 },+		{ "pdt", -420 }, { "pst", -480 },+	};+	long day, mon, year, hh, mm, ss = 0, z = 0, y, era, yoe, doy;+	const char *p;+	size_t n, i;+	int neg;++	s = skipcfws(s);+	if (isalpha((unsigned char)*s)) {+		while (isalpha((unsigned char)*s))+			s++;+		s = skipcfws(s);+	}+	if (!digits(&s, 2, &day))+		return -1;+	s = skipcfws(s);+	for (n = 0; isalpha((unsigned char)s[n]); n++)+		;+	if (n < 3)+		return -1;+	for (mon = 0; mon < 12 && strncasecmp(s, months + 3 * mon, 3); mon++)+		;+	if (mon == 12)+		return -1;+	s = skipcfws(s + n);+	if ((n = digits(&s, 4, &year)) < 2)+		return -1;+	if (n == 2)+		year += year < 50 ? 2000 : 1900;+	else if (n == 3)+		year += 1900;+	s = skipcfws(s);+	if (!digits(&s, 2, &hh))+		return -1;+	if (*(s = skipcfws(s)) != ':')+		return -1;+	s = skipcfws(s + 1);+	if (!digits(&s, 2, &mm))+		return -1;+	if (*(p = skipcfws(s)) == ':') {+		s = skipcfws(p + 1);+		if (!digits(&s, 2, &ss))+			return -1;+	}+	s = skipcfws(s);+	if (*s == '+' || *s == '-') {+		neg = *s++ == '-';+		if (digits(&s, 4, &z) != 4 || z / 100 > 23 || z % 100 > 59)+			return -1;+		z = z / 100 * 60 + z % 100;+		if (neg)+			z = -z;+	} else if (isalpha((unsigned char)*s)) {+		for (n = 0; isalpha((unsigned char)s[n]); n++)+			;+		for (i = 0; i < LENGTH(zones); i++)+			if (n == 3 && !strncasecmp(s, zones[i].name, 3))+				z = zones[i].off;+	}+	if (year < 1900 || year > 9999 || day < 1 || day > mdays[mon]+	|| (mon == 1 && day == 29 && (year % 4 || (!(year % 100)+	&& year % 400))) || hh > 23 || mm > 59 || ss > 60)+		return -1;+	y = year - (mon < 2);+	era = y / 400;+	yoe = y - era * 400;+	doy = (153 * (mon + (mon > 1 ? -2 : 10)) + 2) / 5 + day - 1;+	*t = (long long)(era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy+		- 719468) * 86400 + hh * 3600 + mm * 60 + ss - z * 60;+	*tz = (int)z;+	return 0;+}++void+parsefrom(const char *v, Msg *m)+{+	Buf ph = { 0 }, cm = { 0 }, ad = { 0 };+	const char *p;+	int depth = 0, quote = 0, angle = 0, done = 0;++	for (p = v; *p; p++) {+		if (depth) {+			if (*p == '\\' && p[1])+				p++;+			else if (*p == '(')+				depth++;+			else if (*p == ')' && !--depth)+				continue;+			bufc(&cm, *p);+		} else if (quote) {+			if (*p == '\\' && p[1])+				p++;+			else if (*p == '"') {+				quote = 0;+				continue;+			}+			bufc(&ph, *p);+		} else if (angle) {+			if (*p == '>') {+				angle = 0;+				done = 1;+			} else {+				bufc(&ad, *p);+			}+		} else if (*p == '(') {+			depth = 1;+		} else if (*p == '"') {+			quote = 1;+		} else if (*p == '<' && !done) {+			angle = 1;+		} else if (!done) {+			bufc(&ph, *p);+		}+	}+	bufadd(&ph, "", 0);+	bufadd(&cm, "", 0);+	if (ad.n || done) {+		m->addr = clean(ad.s, ad.n);+		m->name = field(*ph.s ? ph.s : cm.s);+	} else {+		m->addr = clean(ph.s, ph.n);+		m->name = field(cm.s);+	}+	free(ph.s);+	free(cm.s);+	free(ad.s);+}++void+parsepart(const char *s, size_t n, Part *p)+{+	char *ct, *cte, *cd;+	size_t h, b, i;++	h = hdrend(s, n, &b);+	p->body = s + b;+	p->len = n - b;+	ct = hdrget(s, h, "Content-Type");+	cte = hdrget(s, h, "Content-Transfer-Encoding");+	cd = hdrget(s, h, "Content-Disposition");+	for (i = 0; ct && ct[i] && i + 1 < sizeof(p->type)+	     && (isalnum((unsigned char)ct[i]) || strchr("/-+._", ct[i])); i++)+		p->type[i] = tolower((unsigned char)ct[i]);+	p->type[i] = '\0';+	if (!strchr(p->type, '/'))+		strcpy(p->type, "text/plain");+	for (i = 0; cte && cte[i] && i + 1 < sizeof(p->cte)+	     && (isalnum((unsigned char)cte[i]) || cte[i] == '-'); i++)+		p->cte[i] = tolower((unsigned char)cte[i]);+	p->cte[i] = '\0';+	if (!ct || param(ct, "charset", p->charset, sizeof(p->charset)) < 0)+		p->charset[0] = '\0';+	if (!ct || param(ct, "boundary", p->boundary,+	    sizeof(p->boundary)) < 0)+		p->boundary[0] = '\0';+	if ((!cd || param(cd, "filename", p->name, sizeof(p->name)) < 0)+	&& (!ct || param(ct, "name", p->name, sizeof(p->name)) < 0))+		p->name[0] = '\0';+	free(ct);+	free(cte);+	free(cd);+}++void+printage(long long t)+{+	static const char *units[] = {+		"minute", "hour", "day", "week", "month", "year"+	};+	static const long secs[] = {+		60, HOUR, DAY, 7 * DAY, 30 * DAY, 365 * DAY+	};+	static const long upto[] = {+		HOUR, DAY, 14 * DAY, 60 * DAY, 730 * DAY, LONG_MAX+	};+	static const long fade[] = { 7 * DAY, 30 * DAY, 180 * DAY, 730 * DAY };+	long d, n;+	size_t i, level;++	if (t < 0 || t > MAXTIME) {+		fputs("<td class=\"r age a4\">&mdash;</td>", stdout);+		return;+	}+	if ((d = (long)((long long)time(NULL) - t)) < 0)+		d = 0;+	for (i = 0; d >= upto[i]; i++)+		;+	if ((n = d / secs[i]) < 1)+		n = 1;+	for (level = 0; level < LENGTH(fade) && d >= fade[level]; level++)+		;+	printf("<td class=\"r age a%zu\" title=\"", level);+	printtime(t, 0, "%Y-%m-%d %H:%M UTC");+	printf("\">%ld %s%s</td>", n, units[i], n == 1 ? "" : "s");+}++void+printbody(const char *s, size_t n)+{+	const char *l, *nl, *cls;+	size_t len;+	int patch = 0;++	if (n > left) {+		for (n = left; n && ((unsigned char)s[n] & 0xc0) == 0x80; n--)+			;+		cut = 1;+	}+	left -= n;+	fputs("<pre class=\"body\">", stdout);+	for (l = s; l < s + n; l = nl + 1) {+		nl = memchr(l, '\n', s + n - l);+		len = nl ? (size_t)(nl - l) : (size_t)(s + n - l);+		cls = lineclass(l, len, nl ? nl + 1 : NULL, s + n, &patch);+		if (cls)+			printf("<span class=\"%s\">", cls);+		xmlencoden(l, len);+		if (cls)+			fputs("</span>", stdout);+		if (!nl)+			break;+		putchar('\n');+	}+	fputs("</pre>\n", stdout);+}++void+printmsg(size_t i, int sel)+{+	const Msg *m = &msgs[i];+	Buf b = { 0 };+	int trunc;++	printf("<article class=\"mail%s\" id=\"", sel ? " sel" : "");+	xmlencode(m->id);+	fputs("\">\n<table class=\"meta\">\n<tr><td>From</td><td>", stdout);+	if (*m->name) {+		xmlencode(m->name);+		fputs(" &lt;", stdout);+		xmlencode(m->addr);+		fputs("&gt;", stdout);+	} else {+		xmlencode(m->addr);+	}+	fputs("</td></tr>\n<tr><td>Date</td><td>", stdout);+	printtime(m->date, m->tz, "%Y-%m-%d %H:%M");+	printf(" %c%02d%02d</td></tr>\n<tr><td>Subject</td><td>",+		m->tz < 0 ? '-' : '+', abs(m->tz) / 60, abs(m->tz) % 60);+	xmlencode(*m->subject ? m->subject : "(no subject)");+	fputs("</td></tr>\n</table>\n<p class=\"links\"><a href=\"", stdout);+	msgurl(m->id, "");+	fputs("\">permalink</a><a href=\"", stdout);+	msgurl(m->id, "raw");+	fputs("\">raw</a><a href=\"mailto:", stdout);+	urlencode(list);+	putchar('@');+	xmlencode(domain);+	fputs("?In-Reply-To=%3C", stdout);+	urlencode(m->id);+	fputs("%3E&amp;subject=", stdout);+	if (strncasecmp(m->subject, "re:", 3))+		fputs("Re%3A%20", stdout);+	urlencode(m->subject);+	fputs("\">reply</a></p>\n", stdout);++	left = (size_t)maxbody;+	cut = 0;+	if ((trunc = readfile(m->file, (size_t)maxhdr + 2 * (size_t)maxbody,+	    &b, 0)) < 0) {+		fputs("<p class=\"att\">Message unreadable.</p>\n", stdout);+	} else {+		renderpart(b.s, b.n, 0, i);+		if (cut || trunc) {+			fputs("<p class=\"att\">truncated, see <a href=\"",+				stdout);+			msgurl(m->id, "raw");+			fputs("\">raw</a></p>\n", stdout);+		}+	}+	fputs("</article>\n", stdout);+	free(b.s);+}++void+printtime(long long t, int tz, const char *fmt)+{+	struct tm tm;+	time_t secs;+	char buf[64];++	if (t < 0 || t > MAXTIME)+		return;+	secs = (time_t)(t + tz * 60LL);+	if (!gmtime_r(&secs, &tm))+		return;+	if (strftime(buf, sizeof(buf), fmt, &tm))+		fputs(buf, stdout);+}++void+qp(const char *s, size_t n, int q, Buf *out)+{+	size_t i, j;++	for (i = 0; i < n; i++) {+		if (q && s[i] == '_') {+			bufc(out, ' ');+		} else if (s[i] != '=') {+			bufc(out, s[i]);+		} else if (i + 2 < n && hex(s[i + 1]) >= 0+		&& hex(s[i + 2]) >= 0) {+			bufc(out, hex(s[i + 1]) << 4 | hex(s[i + 2]));+			i += 2;+		} else {+			for (j = i + 1; j < n && (s[j] == ' ' || s[j] == '\t');+			     j++)+				;+			if (j < n && s[j] == '\n')+				i = j;+			else if (j + 1 < n && s[j] == '\r' && s[j + 1] == '\n')+				i = j + 1;+			else+				bufc(out, '=');+		}+	}+}++void+rawpage(size_t t)+{+	char buf[8192];+	size_t n;+	FILE *fp;++	if (!(fp = openmsg(msgs[t].file))) {+		notfound();+		return;+	}+	httpheader(200, "text/plain; charset=utf-8");+	while ((n = fread(buf, 1, sizeof(buf), fp)))+		fwrite(buf, 1, n, stdout);+	fclose(fp);+}++int+readfile(const char *file, size_t max, Buf *b, int hdronly)+{+	char chunk[4096];+	size_t want, r, i;+	FILE *fp;+	int trunc = 0;++	bufadd(b, "", 0);+	if (!(fp = openmsg(file)))+		return -1;+	for (;;) {+		if (!(want = max - b->n)) {+			trunc = fgetc(fp) != EOF;+			break;+		}+		if (want > sizeof(chunk))+			want = sizeof(chunk);+		if (!(r = fread(chunk, 1, want, fp)))+			break;+		i = b->n > 2 ? b->n - 2 : 0;+		bufadd(b, chunk, r);+		for (; hdronly && i + 1 < b->n; i++)+			if (b->s[i] == '\n' && (b->s[i + 1] == '\n'+			|| (b->s[i + 1] == '\r' && i + 2 < b->n+			&& b->s[i + 2] == '\n')))+				hdronly = 2;+		if (hdronly == 2)+			break;+	}+	fclose(fp);+	return trunc;+}++void+readline(const char *name, const char *file, char *buf, size_t len)+{+	char path[PATH_MAX];+	FILE *fp;++	buf[0] = '\0';+	if (bprintf(path, sizeof(path), "%s/%s/%s", root, name, file) < 0+	|| !(fp = fopen(path, "r")))+		return;+	if (fgets(buf, (int)len, fp))+		buf[strcspn(buf, "\r\n")] = '\0';+	fclose(fp);+}++void+renderpart(const char *s, size_t n, int depth, size_t m)+{+	Buf raw = { 0 }, u = { 0 }, t = { 0 };+	Part p;++	if (cut)+		return;+	parsepart(s, n, &p);+	if (!strncmp(p.type, "multipart/", 10) && *p.boundary+	&& depth < maxdepth) {+		multipart(&p, depth, m);+	} else if (!strcmp(p.type, "text/plain")+	|| !strcmp(p.type, "text/x-diff") || !strcmp(p.type, "text/x-patch")) {+		decode(&p, &raw);+		if (tou8(p.charset, raw.s, raw.n, &u) < 0)+			bufadd(&u, raw.s, raw.n);+		sanitize(u.s, u.n, &t, 1);+		printbody(t.s, t.n);+	} else if (!strcmp(p.type, "text/html")) {+		fputs("<p class=\"att\">HTML part not shown, see <a href=\"",+			stdout);+		msgurl(msgs[m].id, "raw");+		fputs("\">raw</a></p>\n", stdout);+	} else {+		attachment(&p);+	}+	free(raw.s);+	free(u.s);+	free(t.s);+}++void+route(char *path)+{+	char *rest;+	size_t n, t;+	int page = 0;++	while (*path == '/')+		path++;+	if (!*path) {+		indexpage();+		return;+	}+	if ((rest = strchr(path, '/')))+		*rest++ = '\0';+	if (!islist(path)) {+		notfound();+		return;+	}+	list = path;+	if (!rest || !*rest) {+		listpage();+		return;+	}+	n = strlen(rest);+	if (rest[n - 1] == '/') {+		rest[--n] = '\0';+	} else if (n > 4 && !strcmp(rest + n - 4, "/raw")) {+		rest[n -= 4] = '\0';+		page = 1;+	} else if (n > 7 && !strcmp(rest + n - 7, "/t.mbox")) {+		rest[n -= 7] = '\0';+		page = 2;+	}+	if (!validid(rest, n)) {+		notfound();+		return;+	}+	scan(list);+	if ((t = byid(rest)) == NONE)+		notfound();+	else if (page == 1)+		rawpage(t);+	else if (page == 2)+		mboxpage(t);+	else+		threadpage(t);+}++void+sanitize(const char *s, size_t n, Buf *out, int multi)+{+	const unsigned char *p = (const unsigned char *)s, *e = p + n;+	unsigned long c;+	size_t i, len;++	while (p < e) {+		c = *p;+		if (c < 0x80) {+			if (c == '\n' || c == '\t' || (c == '\r' && !multi))+				bufc(out, multi ? (int)c : ' ');+			else if ((c < 0x20 && c != '\r') || c == 0x7f)+				bufadd(out, REPL, 3);+			else if (c != '\r')+				bufc(out, (int)c);+			p++;+			continue;+		}+		if (c >= 0xc2 && c <= 0xdf) {+			len = 2;+			c &= 0x1f;+		} else if (c >= 0xe0 && c <= 0xef) {+			len = 3;+			c &= 0x0f;+		} else if (c >= 0xf0 && c <= 0xf4) {+			len = 4;+			c &= 0x07;+		} else {+			len = 0;+		}+		for (i = 1; i < len; i++) {+			if (p + i >= e || (p[i] & 0xc0) != 0x80)+				len = 0;+			else+				c = c << 6 | (p[i] & 0x3f);+		}+		if (!len || (len == 3 && c < 0x800)+		|| (len == 4 && (c < 0x10000 || c > 0x10ffff))+		|| (c >= 0xd800 && c <= 0xdfff)) {+			bufadd(out, REPL, 3);+			p++;+			continue;+		}+		if (c < 0xa0 || (c >= 0x202a && c <= 0x202e)+		|| (c >= 0x2066 && c <= 0x2069))+			bufadd(out, REPL, 3);+		else+			bufadd(out, (const char *)p, len);+		p += len;+	}+}++void+scan(const char *name)+{+	static const char *sub[] = { "new", "cur" };+	char dir[PATH_MAX], path[PATH_MAX];+	struct dirent *e;+	struct stat st;+	Msg *v;+	DIR *dp;+	size_t i, j, n = 0, cap = 0;++	freemsgs();+	for (i = 0; i < LENGTH(sub); i++) {+		if (bprintf(dir, sizeof(dir), "%s/%s/archive/%s", root, name,+		    sub[i]) < 0 || !(dp = opendir(dir)))+			continue;+		while ((e = readdir(dp))) {+			if (e->d_name[0] == '.'+			|| bprintf(path, sizeof(path), "%s/%s", dir,+			   e->d_name) < 0+			|| lstat(path, &st) < 0 || !S_ISREG(st.st_mode))+				continue;+			if (n == cap) {+				cap = cap ? cap * 2 : 256;+				if (!(v = realloc(msgs, cap * sizeof(*v))))+					die("out of memory");+				msgs = v;+			}+			memset(&msgs[n], 0, sizeof(*msgs));+			msgs[n].file = xstrndup(path, strlen(path));+			msgs[n].mtime = (long long)st.st_mtime;+			n++;+		}+		closedir(dp);+	}+	if (n)+		qsort(msgs, n, sizeof(*msgs), cmpmtime);+	for (i = j = 0; i < n; i++) {+		if (i < (size_t)maxmsgs && loadmsg(&msgs[i]) >= 0)+			msgs[j++] = msgs[i];+		else+			freemsg(&msgs[i]);+	}+	nmsgs = j;+	if (nmsgs)+		qsort(msgs, nmsgs, sizeof(*msgs), cmpfirst);+	for (i = j = 0; i < nmsgs; i++) {+		if (j && !strcmp(msgs[j - 1].id, msgs[i].id))+			freemsg(&msgs[i]);+		else+			msgs[j++] = msgs[i];+	}+	nmsgs = j;+	if (nmsgs)+		qsort(msgs, nmsgs, sizeof(*msgs), cmpdate);+	if (!(ids = calloc(nmsgs + 1, sizeof(*ids))))+		die("out of memory");+	for (i = 0; i < nmsgs; i++)+		ids[i] = i;+	if (nmsgs)+		qsort(ids, nmsgs, sizeof(*ids), cmpid);+	threadmsgs();+}++const char *+skipcfws(const char *s)+{+	int depth = 0;++	for (; *s; s++) {+		if (depth && *s == '\\' && s[1])+			s++;+		else if (*s == '(')+			depth++;+		else if (depth && *s == ')')+			depth--;+		else if (!depth && !isspace((unsigned char)*s) && *s != ',')+			break;+	}+	return s;+}++void+threadmsgs(void)+{+	size_t *stack, i, j, k, n, p, r;++	for (i = 0; i < nmsgs; i++) {+		msgs[i].parent = msgs[i].root = NONE;+		if (msgs[i].irt && (p = byid(msgs[i].irt)) != NONE && p != i)+			msgs[i].parent = p;+		for (k = msgs[i].nrefs; msgs[i].parent == NONE && k; k--)+			if ((p = byid(msgs[i].refs[k - 1])) != NONE && p != i)+				msgs[i].parent = p;+	}+	if (!(stack = calloc(nmsgs + 1, sizeof(*stack))))+		die("out of memory");+	for (i = 0; i < nmsgs; i++) {+		n = 0;+		for (j = i; msgs[j].root == NONE && msgs[j].seen != i + 1;+		     j = msgs[j].parent) {+			msgs[j].seen = i + 1;+			stack[n++] = j;+			if (msgs[j].parent == NONE)+				break;+		}+		r = msgs[j].root != NONE ? msgs[j].root : j;+		for (k = 0; k < n; k++)+			msgs[stack[k]].root = r;+	}+	free(stack);+}++void+threadpage(size_t t)+{+	const Msg *r = &msgs[msgs[t].root];+	size_t i, n = 0;++	if (msgs[t].root != t) {+		printf("Status: 302 Found\r\nLocation: %s/", base);+		urlencode(list);+		putchar('/');+		urlencode(r->id);+		fputs("/#", stdout);+		urlencode(msgs[t].id);+		fputs("\r\n", stdout);+		httpheader(302, "text/plain; charset=utf-8");+		return;+	}+	for (i = 0; i < nmsgs; i++)+		if (msgs[i].root == t)+			n++;+	pagebegin(200, *r->subject ? r->subject : "(no subject)");+	listheader(0);+	fputs("<h2 class=\"subject\">", stdout);+	xmlencode(*r->subject ? r->subject : "(no subject)");+	printf("</h2>\n<p class=\"info\">%zu message%s &middot; <a href=\"",+		n, n == 1 ? "" : "s");+	msgurl(r->id, "t.mbox");+	fputs("\">t.mbox</a></p>\n", stdout);+	for (i = 0; i < nmsgs; i++)+		if (msgs[i].root == t)+			printmsg(i, i == t);+	pageend();+}++int+tou8(const char *cs, const char *s, size_t n, Buf *out)+{+	char name[64], tmp[1024], *in, *o;+	size_t i, il, ol, r;+	iconv_t cd;+	unsigned char c;++	for (i = 0; cs[i] && cs[i] != '*'; i++) {+		if (i + 1 >= sizeof(name) || !(isalnum((unsigned char)cs[i])+		|| strchr("-_.:", cs[i])))+			return -1;+		name[i] = cs[i];+	}+	name[i] = '\0';+	if (!*name || !strcasecmp(name, "utf-8") || !strcasecmp(name, "utf8")+	|| !strcasecmp(name, "us-ascii") || !strcasecmp(name, "ascii")) {+		bufadd(out, s, n);+		return 0;+	}+	if (!strcasecmp(name, "iso-8859-1") || !strcasecmp(name, "latin1")) {+		for (i = 0; i < n; i++) {+			c = (unsigned char)s[i];+			if (c < 0x80) {+				bufc(out, c);+			} else {+				bufc(out, 0xc0 | c >> 6);+				bufc(out, 0x80 | (c & 0x3f));+			}+		}+		return 0;+	}+	if ((cd = iconv_open("UTF-8", name)) == (iconv_t)-1)+		return -1;+	in = (char *)s;+	il = n;+	while (il) {+		o = tmp;+		ol = sizeof(tmp);+		r = iconv(cd, &in, &il, &o, &ol);+		bufadd(out, tmp, o - tmp);+		if (r != (size_t)-1 || errno == E2BIG)+			continue;+		bufadd(out, REPL, 3);+		if (errno != EILSEQ)+			break;+		in++;+		il--;+	}+	o = tmp;+	ol = sizeof(tmp);+	iconv(cd, NULL, NULL, &o, &ol);+	bufadd(out, tmp, o - tmp);+	iconv_close(cd);+	return 0;+}++void+urlencode(const char *s)+{+	for (; *s; s++) {+		if (isalnum((unsigned char)*s) || strchr("-._~@", *s))+			putchar(*s);+		else+			printf("%%%02X", (unsigned char)*s);+	}+}++int+validid(const char *s, size_t n)+{+	size_t i;++	if (!n || n > FIELDLEN)+		return 0;+	for (i = 0; i < n; i++)+		if ((unsigned char)s[i] <= ' ' || s[i] == 0x7f || s[i] == '<'+		|| s[i] == '>')+			return 0;+	return 1;+}++int+validlist(const char *s)+{+	size_t n;++	for (n = 0; s[n]; n++)+		if (!islower((unsigned char)s[n])+		&& !isdigit((unsigned char)s[n]) && !(n && s[n] == '-'))+			return 0;+	return n && n <= 64;+}++void+xmlencode(const char *s)+{+	if (s)+		xmlencoden(s, strlen(s));+}++void+xmlencoden(const char *s, size_t len)+{+	for (; len; s++, len--) {+		switch (*s) {+		case '<':  fputs("&lt;", stdout);   break;+		case '>':  fputs("&gt;", stdout);   break;+		case '&':  fputs("&amp;", stdout);  break;+		case '"':  fputs("&quot;", stdout); break;+		case '\'': fputs("&#39;", stdout);  break;+		default:   putchar(*s);+		}+	}+}++char *+xstrndup(const char *s, size_t n)+{+	char *r;++	if (!(r = malloc(n + 1)))+		die("out of memory");+	if (n)+		memcpy(r, s, n);+	r[n] = '\0';+	return r;+}++/* main */+int+main(void)+{+	char path[2048];+	const char *q;+	struct rlimit rl;++	rl.rlim_cur = rl.rlim_max = maxcpu;+	setrlimit(RLIMIT_CPU, &rl);+	root = envor("HERBARIUM_ROOT", root);+	domain = envor("HERBARIUM_DOMAIN", domain);+	css = envor("HERBARIUM_CSS", css);+	icon = envor("HERBARIUM_ICON", icon);+	title = envor("HERBARIUM_TITLE", title);+	base = envor("SCRIPT_NAME", "");+	q = envor("QUERY_STRING", "");+	if (!strncmp(q, "ofs=", 4) && (ofs = strtol(q + 4, NULL, 10)) < 0)+		ofs = 0;++	if (bprintf(path, sizeof(path), "%s", envor("PATH_INFO", "")) < 0)+		notfound();+	else+		route(path);+	return 0;+}

herbarium/icon.svg

@@ -0,0 +1,26 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">+<style>+.g { stroke: #8cc56b; }+.f { fill: #8cc56b; }+.p { fill: #e58fa6; }+.c { fill: #f6e7a1; }+@media (prefers-color-scheme: light) {+	.g { stroke: #2f6d1a; }+	.f { fill: #2f6d1a; }+	.p { fill: #c2456b; }+	.c { fill: #e8b931; }+}+</style>+<path class="g f" d="M3 28V18a13 13 0 0 1 26 0v10z" fill-opacity=".14" stroke-width="2" stroke-linejoin="round"/>+<path class="g" d="M10 28V6.6M22 28V6.6M3 18h26" stroke-width="1.25" stroke-opacity=".55"/>+<path class="g" d="M16 28v-11" stroke-width="1.6" stroke-linecap="round"/>+<path class="f" d="M16 25.5c-2.6 0-3.6-1.6-3.6-3.1 2.1 0 3.6 1 3.6 3.1zM16 23.5c2.6 0 3.6-1.6 3.6-3.1-2.1 0-3.6 1-3.6 3.1z"/>+<g class="p" transform="translate(16 13.5)">+<ellipse rx="1.6" ry="2.7" transform="translate(0 -2.6)"/>+<ellipse rx="1.6" ry="2.7" transform="rotate(72) translate(0 -2.6)"/>+<ellipse rx="1.6" ry="2.7" transform="rotate(144) translate(0 -2.6)"/>+<ellipse rx="1.6" ry="2.7" transform="rotate(216) translate(0 -2.6)"/>+<ellipse rx="1.6" ry="2.7" transform="rotate(288) translate(0 -2.6)"/>+</g>+<circle class="c" cx="16" cy="13.5" r="1.2"/>+</svg>

herbarium/style.css

@@ -0,0 +1,432 @@+:root {+	--bg: #0d0f0d;+	--fg: #c8cec3;+	--hi: #eef2ea;+	--dim: #6c7569;+	--faint: #1c201c;+	--leaf: #9fd47c;+	--petal: #f08fae;+	--a0: #9fd47c;+	--a1: #b9cfa8;+	--a2: #8d9689;+	--a3: #626a5f;+	--a4: #424840;+}++@media (prefers-color-scheme: light) {+	:root {+		--bg: #fafaf7;+		--fg: #2a2e28;+		--hi: #000;+		--dim: #7a8277;+		--faint: #ecefe8;+		--leaf: #2f6d1a;+		--petal: #b0345c;+		--a0: #2f6d1a;+		--a1: #577a47;+		--a2: #7a8277;+		--a3: #a2a89f;+		--a4: #c4c8c1;+	}+}++body {+	max-width: 980px;+	margin: 0 auto;+	padding: 1.25rem 1.5rem 3rem;+	background: var(--bg);+	color: var(--fg);+	font: 14px/1.6 ui-monospace, "JetBrains Mono", "DejaVu Sans Mono", monospace;+}++a {+	color: var(--hi);+	text-decoration: none;+}++a:hover {+	color: var(--leaf);+}++#top {+	display: flex;+	gap: 2ch;+	padding-bottom: 0.75rem;+	border-bottom: 1px dashed var(--dim);+}++#top .brand img {+	width: 1.3em;+	height: 1.3em;+	vertical-align: -0.3em;+}++#top nav, header nav {+	display: flex;+	gap: 1ch;+}++#top nav a, header nav a {+	padding: 0 1ch;+	color: var(--dim);+}++#top nav a.on, header nav a.on {+	background: var(--leaf);+	color: var(--bg);+}++header {+	padding: 2.25rem 0 0;+}++h1 {+	margin: 0;+	font-size: 1.75rem;+	line-height: 1.2;+	letter-spacing: -0.02em;+	color: var(--hi);+}++.desc {+	color: var(--dim);+}++header .desc {+	margin: 0.35rem 0 0;+}++.clone {+	display: inline-block;+	margin: 1.1rem 0 0;+	padding: 0.4rem 1.5ch 0.4rem 1.25ch;+	background: var(--faint);+	border-left: 2px solid var(--leaf);+}++.clone span {+	color: var(--dim);+	user-select: none;+}++.clone span::before {+	content: "$ ";+	color: var(--leaf);+}++header nav {+	margin: 1.25rem 0 0;+}++header nav a {+	margin-left: -1ch;+	margin-right: 1ch;+}++main {+	padding-top: 1.5rem;+}++table {+	width: 100%;+	border-collapse: collapse;+}++th {+	padding: 0 2ch 0.4rem 0;+	text-align: left;+	font-weight: normal;+	color: var(--dim);+}++td {+	padding: 0.1rem 2ch 0.1rem 0;+	white-space: nowrap;+	vertical-align: top;+}++th:last-child, td:last-child {+	padding-right: 0;+}++tbody tr:hover td {+	background: var(--faint);+}++.r {+	text-align: right;+}++.desc, .msg {+	width: 100%;+	white-space: normal;+}++.msg a {+	color: var(--fg);+}++.dir, .owner, .date, .num {+	color: var(--dim);+}++.ins { color: var(--leaf); }+.del { color: var(--petal); }+.a0 { color: var(--a0); }+.a1 { color: var(--a1); }+.a2 { color: var(--a2); }+.a3 { color: var(--a3); }+.a4 { color: var(--a4); }++header nav .ref {+	margin-left: auto;+	color: var(--dim);+}++header nav .ref a {+	margin: 0;+	padding: 0;+	color: var(--hi);+}++h2 {+	margin: 1.75rem 0 0.5rem;+	font-size: 1em;+	color: var(--dim);+}++h2:first-child {+	margin-top: 0;+}++h2:not(.subject)::before {+	content: "## ";+}++.pager {+	display: flex;+	gap: 3ch;+	margin-top: 1.25rem;+	color: var(--dim);+}++.pager a {+	color: var(--leaf);+}++.path {+	margin: 0 0 1rem;+	color: var(--dim);+}++.path .info {+	float: right;+}++.mode, .num {+	color: var(--dim);+}++.dirent a {+	color: var(--leaf);+}++pre {+	margin: 0;+	font: inherit;+	overflow-x: auto;+}++.blob {+	position: relative;+	padding-left: 7ch;+}++.blob a {+	position: absolute;+	left: 0;+	width: 5ch;+	text-align: right;+	color: var(--a4);+	user-select: none;+}++.blob a:hover, .blob a:target {+	color: var(--leaf);+}++.subject {+	margin: 0 0 1rem;+	font-size: 1.15rem;+	color: var(--hi);+}++.body {+	margin: 0 0 1.25rem;+	color: var(--fg);+	white-space: pre-wrap;+}++.meta {+	width: auto;+	margin-bottom: 1.5rem;+}++.meta td:first-child {+	color: var(--dim);+}++.stat {+	width: auto;+}++.stat td {+	padding-top: 0;+	padding-bottom: 0;+}++.bar {+	letter-spacing: -0.05em;+}++.total {+	margin: 0.5rem 0 2rem;+	color: var(--dim);+}++h3 {+	margin: 1.75rem 0 0;+	padding: 0.3rem 1ch;+	font-size: 1em;+	font-weight: normal;+	color: var(--hi);+	background: var(--faint);+}++.diff {+	padding: 0.5rem 0;+}++.diff span {+	display: block;+}++.diff .h {+	color: var(--dim);+}++.diff .i {+	color: var(--leaf);+	background: color-mix(in srgb, var(--leaf) 8%, transparent);+}++.diff .d {+	color: var(--petal);+	background: color-mix(in srgb, var(--petal) 8%, transparent);+}++@media (max-width: 640px) {+	.sm {+		display: none;+	}+}++.diff span {+	min-height: 1.6em;+}++.tree .name, .tree .dirent {+	width: 100%;+}++.refs .name {+	width: 100%;+}++h1 a {+	color: inherit;+}++.addrs {+	width: auto;+	margin: 1.1rem 0 0;+	padding: 0.4rem 1.5ch 0.4rem 1.25ch;+	background: var(--faint);+	border-left: 2px solid var(--leaf);+	border-collapse: separate;+}++.addrs td {+	padding: 0 2ch 0 0;+}++.addrs td:first-child {+	color: var(--dim);+	user-select: none;+}++.addrs tr:hover td {+	background: none;+}++.info {+	margin: 0 0 1.5rem;+	color: var(--dim);+}++.info a, .links a {+	color: var(--leaf);+}++.mail {+	margin: 0 0 1.75rem;+	padding: 0.5rem 0 0.25rem 1.5ch;+	border-left: 2px solid var(--faint);+}++.mail:target, .mail.sel {+	border-left-color: var(--leaf);+}++.mail .meta {+	margin-bottom: 0.5rem;+}++.mail .meta td {+	white-space: normal;+	overflow-wrap: anywhere;+}++.mail .meta tr:hover td {+	background: none;+}++.links {+	display: flex;+	gap: 2ch;+	margin: 0 0 1rem;+}++.mail .body {+	margin: 0 0 1rem;+	overflow-wrap: anywhere;+}++.body .q { color: var(--dim); }+.body .f { color: var(--hi); font-weight: bold; }+.body .h { color: var(--a2); }+.body .i { color: var(--leaf); }+.body .d { color: var(--petal); }++.att {+	margin: 0 0 1rem;+	color: var(--dim);+}++.att a {+	color: var(--leaf);+}++.threads .msg a, .lists .name a {+	white-space: normal;+}

pollen/pollen.1

@@ -134,6 +134,8 @@ for each open request, written by pollen. .TP .B archive/ Maildir of every distributed post, exactly as sent.+The archive is group-readable for archive readers such as herbarium;+everything else pollen writes is private to its user. .TP .B .lock Lock file serializing changes to

pollen/pollen.c

@@ -785,13 +785,13 @@ request(const char *act) 	} 	if (newtoken(tok) < 0 	|| bprintf(d, sizeof(d), "%s/pending", dir) < 0-	|| (mkdir(d, 0750) < 0 && errno != EEXIST)+	|| (mkdir(d, 0700) < 0 && errno != EEXIST) 	|| bprintf(tmp, sizeof(tmp), "%s/.%s", d, tok) < 0 	|| bprintf(path, sizeof(path), "%s/%s", d, tok) < 0 	|| bprintf(reply, sizeof(reply), "%s+confirm-%s@%s", list, tok, 	           domain) < 0) 		die(EX_TEMPFAIL, "cannot create request");-	fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0640);+	fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0600); 	ok = fd >= 0 && dprintf(fd, "%s %s %lld\n", act, addr, 	                        (long long)time(NULL)) >= 0 && !fsync(fd); 	if (fd >= 0 && close(fd) < 0)@@ -858,7 +858,7 @@ setsub(const char *addr, int add) 	if (bprintf(lock, sizeof(lock), "%s/.lock", dir) < 0 	|| bprintf(file, sizeof(file), "%s/subscribers", dir) < 0 	|| bprintf(tmp, sizeof(tmp), "%s/.subscribers", dir) < 0-	|| (lfd = open(lock, O_RDWR | O_CREAT, 0640)) < 0)+	|| (lfd = open(lock, O_RDWR | O_CREAT, 0600)) < 0) 		return -1; 	memset(&fl, 0, sizeof(fl)); 	fl.l_type = F_WRLCK;@@ -868,7 +868,7 @@ setsub(const char *addr, int add) 			goto end; 	if (!(in = fopen(file, "r")) && errno != ENOENT) 		goto end;-	if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0640)) < 0)+	if ((fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600)) < 0) 		goto end; 	if (!(out = fdopen(fd, "w"))) { 		close(fd);@@ -941,6 +941,7 @@ main(int argc, char *argv[]) 	char ext[256]; 	struct stat st; +	umask(027); 	signal(SIGPIPE, SIG_IGN); 	openlog("pollen", LOG_PID, LOG_MAIL); 	if (argc < 2 || argc > 3)