Commit Diff


commit - 158b818071282f1db2ff66815dca7883ae8b4019
commit + 25fed994674728cf1905a717c1334f78c12781ea
blob - 005bdb3a278e9e0849e6e6b4267b8c5798f4bff6
blob + 0ba7ff4eb378b0ba7e6afead99b05991a4dd552b
--- apub.go
+++ apub.go
@@ -19,12 +19,12 @@ import (
 // @context
 const AtContext string = "https://www.w3.org/ns/activitystreams"
 
+// ContentType is the MIME media type for ActivityPub.
 const ContentType string = "application/activity+json"
 
-const AcceptMediaType string = `application/activity+json; profile="https://www.w3.org/ns/activitystreams"`
-
-// Activities addressed to this collection indicates the activity
-// is available to all users, authenticated or not.
+// PublicCollection is the ActivityPub ID for the special collection indicating public access.
+// Any Activity addressed to this collection is meant to be available to all users,
+// authenticated or not.
 // See W3C Recommendation ActivityPub Section 5.6.
 const PublicCollection string = "https://www.w3.org/ns/activitystreams#Public"
 
@@ -80,6 +80,10 @@ func (act *Activity) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+// Unwrap returns the JSON-encoded Activity, if any, enclosed in act.
+// The Activity may be referenced by ID,
+// in which case the activity is looked up by client or by
+// apub.defaultClient if client is nil.
 func (act *Activity) Unwrap(client *Client) (*Activity, error) {
 	if act.Object == nil {
 		return nil, errors.New("no wrapped activity")
@@ -132,6 +136,13 @@ type PublicKey struct {
 	PublicKeyPEM string `json:"publicKeyPem"`
 }
 
+// Address generates the most likely address of the Actor.
+// The Actor's name (not the username) is used as the address' proper name, if present.
+// Implementors should verify the address using WebFinger.
+// For example, the followers address for Actor ID
+// https://hachyderm.io/users/otl is:
+//
+//	"Oliver Lowe" <otl+followers@hachyderm.io>
 func (a *Actor) Address() *mail.Address {
 	if a.Username == "" && a.Name == "" {
 		return &mail.Address{"", a.ID}
@@ -142,6 +153,15 @@ func (a *Actor) Address() *mail.Address {
 	return &mail.Address{a.Name, addr}
 }
 
+// FollowersAddress generates a non-standard address representing the Actor's followers
+// using plus addressing.
+// It is the Actor's address username part with a "+followers" suffix.
+// The address cannot be resolved using WebFinger.
+//
+// For example, the followers address for Actor ID
+// https://hachyderm.io/users/otl is:
+//
+//	"Oliver Lowe (followers)" <otl+followers@hachyderm.io>
 func (a *Actor) FollowersAddress() *mail.Address {
 	if a.Followers == "" {
 		return &mail.Address{"", ""}
blob - e5c409b090790dfdb9623c5fe12fad3e4def11e1
blob + bfcb8e5d35c75965c081cafe858dbf41b98e644d
--- client.go
+++ client.go
@@ -23,8 +23,11 @@ func LookupActor(id string) (*Actor, error) {
 
 type Client struct {
 	*http.Client
-	Key   *rsa.PrivateKey
-	Actor *Actor
+	// Key is a RSA private key which will be used to sign requests.
+	Key *rsa.PrivateKey
+	// PubKeyID is a URL where the corresponding public key of Key
+	// may be accessed. This must be set if Key is also set.
+	PubKeyID string // actor.PublicKey.ID
 }
 
 func (c *Client) Lookup(id string) (*Activity, error) {
@@ -40,8 +43,8 @@ func (c *Client) Lookup(id string) (*Activity, error) 
 		return nil, err
 	}
 	req.Header.Set("Accept", ContentType)
-	if c.Key != nil && c.Actor != nil {
-		if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil {
+	if c.Key != nil && c.PubKeyID != "" {
+		if err := Sign(req, c.Key, c.PubKeyID); err != nil {
 			return nil, fmt.Errorf("sign http request: %w", err)
 		}
 	}
@@ -95,7 +98,7 @@ func (c *Client) Send(inbox string, activity *Activity
 		return nil, err
 	}
 	req.Header.Set("Content-Type", ContentType)
-	if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil {
+	if err := Sign(req, c.Key, c.PubKeyID); err != nil {
 		return nil, fmt.Errorf("sign outgoing request: %w", err)
 	}
 	resp, err := c.Do(req)
blob - 876dfda95b73ac0831d8bbaf14680b2993c4b797 (mode 644)
blob + /dev/null
--- cmd/apmail/listen.go
+++ /dev/null
@@ -1,235 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"log"
-	"net/http"
-	"net/smtp"
-	"os"
-	"os/user"
-	"path"
-	"strings"
-
-	"olowe.co/apub"
-)
-
-type server struct {
-	acceptFor []user.User
-	relayAddr string
-}
-
-func (srv *server) relay(username string, activity *apub.Activity) {
-	var err error
-	switch activity.Type {
-	case "Note":
-		// check if we need to dereference
-		if activity.Content == "" {
-			activity, err = apub.Lookup(activity.ID)
-			if err != nil {
-				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
-				return
-			}
-		}
-	case "Page":
-		// check if we need to dereference
-		if activity.Name == "" {
-			activity, err = apub.Lookup(activity.ID)
-			if err != nil {
-				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
-				return
-			}
-		}
-	case "Create", "Update":
-		wrapped, err := activity.Unwrap(nil)
-		if err != nil {
-			log.Printf("unwrap from %s: %v", activity.ID, err)
-			return
-		}
-		srv.relay(username, wrapped)
-		return
-	default:
-		return
-	}
-
-	if err := apub.SendMail(srv.relayAddr, nil, "nobody", []string{username}, activity); err != nil {
-		log.Printf("relay %s via SMTP: %v", activity.ID, err)
-	}
-}
-
-func (srv *server) handleInbox(w http.ResponseWriter, req *http.Request) {
-	if req.Method != http.MethodPost {
-		stat := http.StatusMethodNotAllowed
-		http.Error(w, http.StatusText(stat), stat)
-		return
-	}
-	if req.Header.Get("Content-Type") != apub.ContentType {
-		w.Header().Set("Accept", apub.ContentType)
-		w.WriteHeader(http.StatusUnsupportedMediaType)
-		return
-	}
-	// url is https://example.com/{username}/inbox
-	username := path.Dir(req.URL.Path)
-	_, err := user.Lookup(username)
-	if _, ok := err.(user.UnknownUserError); ok {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	} else if err != nil {
-		log.Println("handle inbox:", err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	var accepted bool
-	for i := range srv.acceptFor {
-		if srv.acceptFor[i].Username == username {
-			accepted = true
-		}
-	}
-	if !accepted {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	}
-
-	defer req.Body.Close()
-	var rcv apub.Activity // received
-	if err := json.NewDecoder(req.Body).Decode(&rcv); err != nil {
-		log.Println("decode apub message:", err)
-		http.Error(w, "malformed activitypub message", http.StatusBadRequest)
-		return
-	}
-	activity := &rcv
-	if rcv.Type == "Announce" {
-		var err error
-		activity, err = rcv.Unwrap(nil)
-		if err != nil {
-			err = fmt.Errorf("unwrap apub object in %s: %w", rcv.ID, err)
-			log.Println(err)
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-	}
-	raddr := req.RemoteAddr
-	if req.Header.Get("X-Forwarded-For") != "" {
-		raddr = req.Header.Get("X-Forwarded-For")
-	}
-	if activity.Type != "Like" && activity.Type != "Dislike" {
-		log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr)
-	}
-	switch activity.Type {
-	case "Accept", "Reject":
-		w.WriteHeader(http.StatusAccepted)
-		return
-	case "Create", "Note", "Page", "Article":
-		w.WriteHeader(http.StatusAccepted)
-		log.Printf("accepted %s %s for relay to %s", activity.Type, activity.ID, username)
-		go srv.relay(username, activity)
-		return
-	}
-	w.WriteHeader(http.StatusAccepted)
-}
-
-var home string = os.Getenv("HOME")
-
-func logRequest(next http.Handler) http.HandlerFunc {
-	return func(w http.ResponseWriter, req *http.Request) {
-		// skip logging from checks by load balancer
-		if req.URL.Path == "/" && req.Method == http.MethodHead {
-			next.ServeHTTP(w, req)
-			return
-		}
-		addr := req.RemoteAddr
-		if req.Header.Get("X-Forwarded-For") != "" {
-			addr = req.Header.Get("X-Forwarded-For")
-		}
-		log.Printf("%s %s %s", addr, req.Method, req.URL)
-		next.ServeHTTP(w, req)
-	}
-}
-
-func serveActorFile(name string) http.HandlerFunc {
-	return func(w http.ResponseWriter, req *http.Request) {
-		w.Header().Set("Content-Type", apub.ContentType)
-		http.ServeFile(w, req, name)
-	}
-}
-
-func serveActivityFile(hfsys http.Handler) http.HandlerFunc {
-	return func(w http.ResponseWriter, req *http.Request) {
-		w.Header().Set("Content-Type", apub.ContentType)
-		hfsys.ServeHTTP(w, req)
-	}
-}
-
-func serveWebFingerFile(w http.ResponseWriter, req *http.Request) {
-	if !req.URL.Query().Has("resource") {
-		http.Error(w, "missing resource query parameter", http.StatusBadRequest)
-		return
-	}
-	q := req.URL.Query().Get("resource")
-	if !strings.HasPrefix(q, "acct:") {
-		http.Error(w, "only acct resource lookup supported", http.StatusNotImplemented)
-		return
-	}
-	addr := strings.TrimPrefix(q, "acct:")
-	username, _, ok := strings.Cut(addr, "@")
-	if !ok {
-		http.Error(w, "bad acct lookup: missing @ in address", http.StatusBadRequest)
-		return
-	}
-	fname, err := apub.UserWebFingerFile(username)
-	if _, ok := err.(user.UnknownUserError); ok {
-		http.Error(w, "no such user", http.StatusNotFound)
-		return
-	} else if err != nil {
-		log.Println(err)
-		stat := http.StatusInternalServerError
-		http.Error(w, http.StatusText(stat), stat)
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	http.ServeFile(w, req, fname)
-}
-
-const usage string = "usage: apmail [address]"
-
-func main() {
-	if len(os.Args) > 2 {
-		log.Fatal(usage)
-	}
-
-	current, err := user.Current()
-	if err != nil {
-		log.Fatalf("lookup current user: %v", err)
-	}
-	acceptFor := []user.User{*current}
-
-	raddr := "[::1]:smtp"
-	if len(os.Args) == 2 {
-		raddr = os.Args[1]
-	}
-	sclient, err := smtp.Dial(raddr)
-	if err != nil {
-		log.Fatal(err)
-	}
-	if err := sclient.Noop(); err != nil {
-		log.Fatalf("check connection to %s: %v", raddr, err)
-	}
-	sclient.Quit()
-	sclient.Close()
-
-	srv := &server{
-		relayAddr: raddr,
-		acceptFor: acceptFor,
-	}
-	http.HandleFunc("/.well-known/webfinger", serveWebFingerFile)
-
-	for _, u := range acceptFor {
-		dataDir := path.Join(u.HomeDir, "apubtest")
-		root := fmt.Sprintf("/%s/", u.Username)
-		inbox := path.Join(root, "inbox")
-		hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir)))
-		http.Handle(root, http.StripPrefix(root, hfsys))
-		http.HandleFunc(inbox, srv.handleInbox)
-	}
-	log.Fatal(http.ListenAndServe("[::1]:8082", nil))
-}
blob - /dev/null
blob + 5ad0de9649498f47f2554e606336495f5627d036 (mode 644)
--- /dev/null
+++ cmd/apsend/apsend.go
@@ -0,0 +1,167 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"net/mail"
+	"os"
+	"os/user"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"olowe.co/apub"
+	"olowe.co/apub/internal/sys"
+)
+
+const usage string = "apsend [-F] [-t] rcpt ..."
+
+// Delivers the mail message to the user's Maildir.
+func deliverLocal(username string, msg []byte) error {
+	u, err := user.Lookup(username)
+	if err != nil {
+		return err
+	}
+	inbox := path.Join(u.HomeDir, "Maildir/new")
+	fname := fmt.Sprintf("%s/%d", inbox, time.Now().Unix())
+	return os.WriteFile(fname, msg, 0664)
+}
+
+func wrapCreate(activity *apub.Activity) (*apub.Activity, error) {
+	b, err := json.Marshal(activity)
+	if err != nil {
+		return nil, err
+	}
+	return &apub.Activity{
+		AtContext: activity.AtContext,
+		ID:        activity.ID + "-create",
+		Actor:     activity.AttributedTo,
+		Type:      "Create",
+		Published: activity.Published,
+		To:        activity.To,
+		CC:        activity.CC,
+		Object:    b,
+	}, nil
+}
+
+var tflag bool
+var Fflag bool
+
+func init() {
+	// log.SetFlags...
+	flag.BoolVar(&Fflag, "F", false, "file a copy for the sender")
+	flag.BoolVar(&tflag, "t", false, "read recipients from message")
+	flag.Parse()
+}
+
+const sysName string = "apubtest2.srcbeat.com"
+
+func main() {
+	if tflag {
+		log.Fatal("flag -t not implemented yet")
+	}
+	if len(flag.Args()) == 0 {
+		fmt.Fprintln(os.Stderr, "usage:", usage)
+		os.Exit(1)
+	}
+
+	bmsg, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		log.Fatal(err)
+	}
+	msg, err := mail.ReadMessage(bytes.NewReader(bmsg))
+	if err != nil {
+		log.Fatal(err)
+	}
+	activity, err := apub.UnmarshalMail(msg)
+	if err != nil {
+		log.Fatalln("unmarshal activity from message:", err)
+	}
+
+	var remote []string
+	for _, rcpt := range flag.Args() {
+		if !strings.Contains(rcpt, "@") {
+			if err := deliverLocal(rcpt, bmsg); err != nil {
+				log.Printf("local delivery to %s: %v", rcpt, err)
+			}
+			continue
+		}
+		remote = append(remote, rcpt)
+	}
+
+	var gotErr bool
+	if len(remote) > 0 {
+		if !strings.HasPrefix(activity.AttributedTo, "https://"+sysName) {
+			log.Fatalln("cannot send activity from non-local actor", activity.AttributedTo)
+		}
+
+		from, err := apub.LookupActor(activity.AttributedTo)
+		if err != nil {
+			log.Fatalf("lookup actor %s: %v", activity.AttributedTo, err)
+		}
+		client, err := sys.ClientFor(from.Username, sysName)
+		if err != nil {
+			log.Fatalf("apub cilent for %s: %v", from.Username, err)
+		}
+
+		// overwrite auto generated ID from mail clients
+		if !strings.HasPrefix(activity.ID, "https://") {
+			activity.ID = from.Outbox + "/" + strconv.Itoa(int(activity.Published.Unix()))
+			bmsg, _ = apub.MarshalMail(activity)
+		}
+
+		// Permit this activity for the public, too;
+		// let's not pretend the fediverse is not public access.
+		activity.To = append(activity.To, apub.PublicCollection)
+		create, err := wrapCreate(activity)
+		if err != nil {
+			log.Fatalf("wrap %s %s in Create activity: %v", activity.Type, activity.ID, err)
+		}
+
+		// append outbound activities to the user's outbox so others can fetch it.
+		sysuser, err := user.Lookup(from.Username)
+		if err != nil {
+			log.Fatalf("lookup system user from %s: %v", activity.ID, err)
+		}
+		outbox := path.Join(sys.UserDataDir(sysuser), "outbox")
+		for _, a := range []*apub.Activity{activity, create} {
+			b, err := json.Marshal(a)
+			if err != nil {
+				log.Fatalf("encode %s: %v", activity.ID, err)
+			}
+			fname := path.Base(a.ID)
+			fname = path.Join(outbox, fname)
+			if err := os.WriteFile(fname, b, 0644); err != nil {
+				log.Fatalf("write activity to outbox: %v", err)
+			}
+		}
+
+		for _, rcpt := range remote {
+			ra, err := apub.Finger(rcpt)
+			if err != nil {
+				log.Printf("webfinger %s: %v", rcpt, err)
+				gotErr = true
+				continue
+			}
+			if _, err = client.Send(ra.Inbox, create); err != nil {
+				log.Printf("send %s %s to %s: %v", activity.Type, activity.ID, rcpt, err)
+				gotErr = true
+			}
+		}
+		if Fflag {
+			if err := deliverLocal(from.Username, bmsg); err != nil {
+				log.Printf("file copy for %s: %v", from.Username, err)
+				gotErr = true
+			}
+		}
+	}
+
+	if gotErr {
+		os.Exit(1)
+	}
+}
blob - /dev/null
blob + f98f969282313d199dc35907a74d9c0b7f66367e (mode 644)
--- /dev/null
+++ cmd/apsend/doc.go
@@ -0,0 +1,43 @@
+/*
+Command apsend reads a mail message from the standard input
+and disposes of it based on the provided recipient addresses.
+
+Its usage is:
+
+	apsend [ -F ] [ -t ] rcpt ...
+
+Messages are disposed of in one of two ways:
+
+  - If the recipient refers to a local user, the message is appended to their local mailbox.
+  - If the recipients refers to a remote user or collection, the message is sent via ActivityPub to each recipent's corresponding Actor inbox.
+
+Local recipients are addrsessed as a plain username such as "otl".
+Remote recipients are addressed as an email address,
+such as
+"mort@novum.streats.dev" or
+"otl+followers@hachyderm.io".
+
+apsend is not intended to be executed directly by users.
+Usually it is executed as a mailer by a SMTP server like [apsubmit],
+or by a server which receives ActivityPub activities for local recipients like [apserve].
+
+The flags understood are:
+
+  - *-F* File a copy to the sender's mailbox.
+
+  - *-t* Read recipients from the To: and CC: lines of the message.
+
+# Example
+
+Given the following message in the file greeting.eml:
+
+	From: otl@apubtest2.srcbeat.com
+	To: otl@hachyderm.io
+
+	Hello, Oliver!
+
+Send it with the following command:
+
+	apsend otl@hachyderm.io < greeting.eml
+*/
+package main
blob - /dev/null
blob + 7bf7d0b29c57bd0e8278ae997f90b413dc67756f (mode 644)
--- /dev/null
+++ cmd/apsend/msg.go
@@ -0,0 +1,25 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/mail"
+	"strings"
+)
+
+func encodMsg(msg *mail.Message) []byte {
+	buf := &bytes.Buffer{}
+	fmt.Fprintln(buf, msg.Header.Get("From"))
+	delete(msg.Header, "From")
+	for k, v := range msg.Header {
+		if k == "Subject" {
+			continue
+		}
+		fmt.Fprintf(buf, "%s: %s\n", k, strings.Join(v, ", "))
+	}
+	fmt.Fprintln(buf, "Subject:", msg.Header.Get("Subject"))
+	fmt.Fprintln(buf)
+	io.Copy(buf, msg.Body)
+	return buf.Bytes()
+}
blob - /dev/null
blob + eab3871c626f8918fd0f0d200573a4c080f291d6 (mode 644)
--- /dev/null
+++ cmd/apserve/listen.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"os/user"
+	"path"
+	"strings"
+
+	"olowe.co/apub"
+)
+
+type server struct {
+	acceptFor []user.User
+	relayAddr string
+}
+
+func (srv *server) relay(username string, activity *apub.Activity) {
+	var err error
+	switch activity.Type {
+	case "Note":
+		// check if we need to dereference
+		if activity.Content == "" {
+			activity, err = apub.Lookup(activity.ID)
+			if err != nil {
+				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
+				return
+			}
+		}
+	case "Page":
+		// check if we need to dereference
+		if activity.Name == "" {
+			activity, err = apub.Lookup(activity.ID)
+			if err != nil {
+				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
+				return
+			}
+		}
+	case "Create", "Update":
+		wrapped, err := activity.Unwrap(nil)
+		if err != nil {
+			log.Printf("unwrap from %s: %v", activity.ID, err)
+			return
+		}
+		srv.relay(username, wrapped)
+		return
+	default:
+		return
+	}
+
+	cmd := exec.Command("apsend", username)
+	msg, err := apub.MarshalMail(activity)
+	if err != nil {
+		log.Printf("marshal %s %s to mail message: %v", activity.Type, activity.ID, err)
+		return
+	}
+	cmd.Stdin = bytes.NewReader(msg)
+	cmd.Stderr = os.Stderr
+	err = cmd.Run()
+	if err != nil {
+		log.Printf("execute mailer for %s: %v", activity.ID, err)
+		return
+	}
+}
+
+func (srv *server) handleInbox(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		stat := http.StatusMethodNotAllowed
+		http.Error(w, http.StatusText(stat), stat)
+		return
+	}
+	if req.Header.Get("Content-Type") != apub.ContentType {
+		w.Header().Set("Accept", apub.ContentType)
+		w.WriteHeader(http.StatusUnsupportedMediaType)
+		return
+	}
+	// url is https://example.com/{username}/inbox
+	username := strings.Trim(path.Dir(req.URL.Path), "/")
+	_, err := user.Lookup(username)
+	if _, ok := err.(user.UnknownUserError); ok {
+		log.Println("handle inbox:", err)
+		w.WriteHeader(http.StatusNotFound)
+		return
+	} else if err != nil {
+		log.Println("handle inbox:", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	var accepted bool
+	for i := range srv.acceptFor {
+		if srv.acceptFor[i].Username == username {
+			accepted = true
+		}
+	}
+	if !accepted {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	defer req.Body.Close()
+	var rcv apub.Activity // received
+	if err := json.NewDecoder(req.Body).Decode(&rcv); err != nil {
+		log.Println("decode apub message:", err)
+		http.Error(w, "malformed activitypub message", http.StatusBadRequest)
+		return
+	}
+	activity := &rcv
+	if rcv.Type == "Announce" {
+		var err error
+		activity, err = rcv.Unwrap(nil)
+		if err != nil {
+			err = fmt.Errorf("unwrap apub object in %s: %w", rcv.ID, err)
+			log.Println(err)
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+	}
+	raddr := req.RemoteAddr
+	if req.Header.Get("X-Forwarded-For") != "" {
+		raddr = req.Header.Get("X-Forwarded-For")
+	}
+	if activity.Type != "Like" && activity.Type != "Dislike" {
+		log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr)
+	}
+	switch activity.Type {
+	case "Accept", "Reject":
+		w.WriteHeader(http.StatusAccepted)
+		return
+	case "Create", "Note", "Page", "Article":
+		w.WriteHeader(http.StatusAccepted)
+		log.Printf("accepted %s %s for %s", activity.Type, activity.ID, username)
+		go srv.relay(username, activity)
+		return
+	}
+	w.WriteHeader(http.StatusAccepted)
+}
+
+func logRequest(next http.Handler) http.HandlerFunc {
+	return func(w http.ResponseWriter, req *http.Request) {
+		// skip logging from checks by load balancer
+		if req.URL.Path == "/" && req.Method == http.MethodHead {
+			next.ServeHTTP(w, req)
+			return
+		}
+		addr := req.RemoteAddr
+		if req.Header.Get("X-Forwarded-For") != "" {
+			addr = req.Header.Get("X-Forwarded-For")
+		}
+		log.Printf("%s %s %s", addr, req.Method, req.URL)
+		next.ServeHTTP(w, req)
+	}
+}
+
+func serveActorFile(name string) http.HandlerFunc {
+	return func(w http.ResponseWriter, req *http.Request) {
+		w.Header().Set("Content-Type", apub.ContentType)
+		http.ServeFile(w, req, name)
+	}
+}
+
+func serveActivityFile(hfsys http.Handler) http.HandlerFunc {
+	return func(w http.ResponseWriter, req *http.Request) {
+		w.Header().Set("Content-Type", apub.ContentType)
+		hfsys.ServeHTTP(w, req)
+	}
+}
+
+const usage string = "apserve"
+
+const domain = "apubtest2.srcbeat.com"
+
+func main() {
+	if len(os.Args) > 1 {
+		log.Fatalln("usage:", usage)
+	}
+
+	current, err := user.Current()
+	if err != nil {
+		log.Fatalf("lookup current user: %v", err)
+	}
+	acceptFor := []user.User{*current}
+
+	srv := &server{
+		acceptFor: acceptFor,
+	}
+	http.HandleFunc("/.well-known/webfinger", serveWebFingerFile)
+
+	for _, u := range acceptFor {
+		dataDir := path.Join(u.HomeDir, "apubtest")
+		root := fmt.Sprintf("/%s/", u.Username)
+		hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir)))
+		http.Handle(root, http.StripPrefix(root, hfsys))
+		inbox := path.Join(root, "inbox")
+		http.HandleFunc(inbox, srv.handleInbox)
+	}
+	log.Fatal(http.ListenAndServe("[::1]:8082", nil))
+}
blob - /dev/null
blob + 356e4b4df27a20951d0e75d0bc7add8040b38f5c (mode 644)
--- /dev/null
+++ cmd/apserve/user.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"os/user"
+	"strings"
+
+	"olowe.co/apub"
+	"olowe.co/apub/internal/sys"
+)
+
+func serveActor(w http.ResponseWriter, req http.Request, username string) {
+	actor, err := sys.Actor(username, domain)
+	if err != nil {
+		// for security reasons we lie here; prevents user enumeration
+		log.Println("lookup actor:", err)
+		http.Error(w, "no such actor", http.StatusNotFound)
+		return
+	}
+	w.Header().Set("Content-Type", apub.ContentType)
+	if err := json.NewEncoder(w).Encode(actor); err != nil {
+		log.Printf("encode actor %s: %v", actor.Username, err)
+	}
+}
+
+func serveWebFinger(w http.ResponseWriter, req *http.Request) {
+	if !req.URL.Query().Has("resource") {
+		http.Error(w, "missing resource query parameter", http.StatusBadRequest)
+		return
+	}
+	q := req.URL.Query().Get("resource")
+	if !strings.HasPrefix(q, "acct:") {
+		http.Error(w, "only acct resource lookup supported", http.StatusNotImplemented)
+		return
+	}
+	addr := strings.TrimPrefix(q, "acct:")
+	username, _, ok := strings.Cut(addr, "@")
+	if !ok {
+		http.Error(w, "bad acct lookup: missing @ in address", http.StatusBadRequest)
+		return
+	}
+	jrd, err := sys.JRDFor(username, domain)
+	if _, ok := err.(user.UnknownUserError); ok {
+		http.Error(w, "no such user", http.StatusNotFound)
+		return
+	} else if err != nil {
+		err = fmt.Errorf("webfinger jrd for %s: %v", username, err)
+		log.Print(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := json.NewEncoder(w).Encode(jrd); err != nil {
+		log.Printf("encode webfinger response: %v", err)
+	}
+}
blob - /dev/null
blob + 55b792985762870e72f6b60e22f53c8748830d2f (mode 644)
--- /dev/null
+++ cmd/apsubmit/apsubmit.go
@@ -0,0 +1,18 @@
+package main
+
+import (
+	"log"
+
+	"github.com/emersion/go-smtp"
+)
+
+func main() {
+	srv := smtp.NewServer(&Backend{})
+	srv.Addr = ":2525"
+	srv.Domain = "apubtest2.srcbeat.com"
+	srv.AllowInsecureAuth = true
+
+	if err := srv.ListenAndServe(); err != nil {
+		log.Fatal(err)
+	}
+}
blob - /dev/null
blob + 79f86165289e764f031e0ac473cb523d7918b0bb (mode 644)
--- /dev/null
+++ cmd/apsubmit/server.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/mail"
+	"os"
+	"os/exec"
+	"os/user"
+
+	"github.com/emersion/go-smtp"
+	"olowe.co/apub"
+)
+
+type Backend struct {
+	auth string
+}
+
+func (be *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
+	return &Session{}, nil
+}
+
+type Session struct {
+	recipients []string
+	User       *user.User
+}
+
+func (s *Session) AuthPlain(username, password string) error {
+	u, err := user.Lookup(username)
+	if err != nil {
+		return errors.New("invalid username or password")
+	}
+	// TODO allow other users except for me lol
+	// TODO implement BSD Auth and/or PAM?
+	if u.Username != "otl" {
+		return errors.New("invalid username or password")
+	}
+	if password != "yamum" {
+		return errors.New("invalid username or password")
+	}
+	s.User = u
+	return nil
+}
+
+func (s *Session) Logout() error { return nil }
+func (s *Session) Reset()        {}
+
+func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
+	log.Println("MAIL FROM:", from)
+	return nil
+}
+
+func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
+	log.Println("RCPT TO:", to)
+	addr, err := mail.ParseAddress(to)
+	if err != nil {
+		return err
+	}
+	if _, err = apub.Finger(addr.Address); err != nil {
+		return err
+	}
+	s.recipients = append(s.recipients, to)
+	return nil
+}
+
+func (s *Session) Data(r io.Reader) error {
+	args := append([]string{"-F"}, s.recipients...)
+	cmd := exec.Command("apsend", args...)
+	cmd.Stdin = r
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+	if err1, ok := err.(*exec.ExitError); ok {
+		return fmt.Errorf("execute mailer: %v", string(err1.Stderr))
+	} else if err != nil {
+		return fmt.Errorf("execute mailer: %v", err)
+	}
+	return nil
+}
blob - afc05ba205700e52300473ed8e2a3a1b3ed6faef
blob + 1e25afd5c580e5a4af28ac994ad0df2b418c244e
--- mail.go
+++ mail.go
@@ -91,15 +91,19 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 		return nil, fmt.Errorf("webfinger From: %w", err)
 	}
 
-	to, err := msg.Header.AddressList("To")
-	if err != nil {
-		return nil, fmt.Errorf("parse To address list: %w", err)
+	var wto, wcc []string
+	if msg.Header.Get("To") != "" {
+		to, err := msg.Header.AddressList("To")
+		// ignore missing To line. Some ActivityPub servers only have the
+		// PublicCollection listed, which we don't care about.
+		if err != nil {
+			return nil, fmt.Errorf("parse To address list: %w", err)
+		}
+		wto, err = fingerAll(to)
+		if err != nil {
+			return nil, fmt.Errorf("webfinger To addresses: %w", err)
+		}
 	}
-	wto, err := fingerAll(to)
-	if err != nil {
-		return nil, fmt.Errorf("webfinger To addresses: %w", err)
-	}
-	var wcc []string
 	if msg.Header.Get("CC") != "" {
 		cc, err := msg.Header.AddressList("CC")
 		if err != nil {
blob - /dev/null
blob + 8aa85921ec7f83e153d9158bee7c546bc60713f2 (mode 644)
--- /dev/null
+++ internal/sys/user.go
@@ -0,0 +1,120 @@
+package sys
+
+import (
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"os/user"
+	"path"
+
+	"olowe.co/apub"
+	"webfinger.net/go/webfinger"
+)
+
+func UserDataDir(u *user.User) string {
+	return path.Join(u.HomeDir, "apubtest")
+}
+
+func ConfigDir(u *user.User) (string, error) {
+	paths := []string{
+		path.Join(u.HomeDir, ".config/apubtest"),             // Unix-like
+		path.Join(u.HomeDir, "Application Support/apubtest"), // macOS
+		path.Join(u.HomeDir, "lib/apubtest"),                 // Plan 9
+	}
+	for i := range paths {
+		if _, err := os.Stat(paths[i]); err == nil {
+			return paths[i], nil
+		}
+	}
+	return "", fmt.Errorf("no apubtest dir")
+}
+
+func Actor(name, host string) (*apub.Actor, error) {
+	u, err := user.Lookup(name)
+	if err != nil {
+		return nil, err
+	}
+	uri, err := url.Parse("https://" + host)
+	if err != nil {
+		return nil, fmt.Errorf("bad host: %w", err)
+	}
+	uri.Path = path.Join("/", u.Username)
+	root := uri.String()
+
+	cdir, err := ConfigDir(u)
+	if err != nil {
+		return nil, fmt.Errorf("find config directory: %w", err)
+	}
+	pubkey, err := os.ReadFile(path.Join(cdir, "public.pem"))
+	if err != nil {
+		return nil, fmt.Errorf("read public key file: %w", err)
+	}
+	return &apub.Actor{
+		AtContext: apub.AtContext,
+		ID:        root + "/actor.json",
+		Type:      "Person",
+		Name:      u.Name,
+		Username:  u.Username,
+		Inbox:     root + "/inbox",
+		Outbox:    root + "/outbox",
+		PublicKey: apub.PublicKey{
+			ID:           root + "/actor.json#main-key",
+			Owner:        root + "/actor.json",
+			PublicKeyPEM: string(pubkey),
+		},
+	}, nil
+}
+
+func ClientFor(username, host string) (*apub.Client, error) {
+	sysuser, err := user.Lookup(username)
+	if err != nil {
+		return nil, err
+	}
+	actor, err := Actor(sysuser.Username, host)
+	if err != nil {
+		return nil, fmt.Errorf("load system actor: %w", err)
+	}
+	cdir, err := ConfigDir(sysuser)
+	if err != nil {
+		return nil, fmt.Errorf("find config dir: %w", err)
+	}
+
+	key, err := loadKey(path.Join(cdir, "private.pem"))
+	if err != nil {
+		return nil, fmt.Errorf("load private key: %w", err)
+	}
+	return &apub.Client{
+		Client:   http.DefaultClient,
+		Key:      key,
+		PubKeyID: actor.PublicKey.ID,
+	}, nil
+}
+
+func loadKey(name string) (*rsa.PrivateKey, error) {
+	b, err := os.ReadFile(name)
+	if err != nil {
+		return nil, err
+	}
+	block, _ := pem.Decode(b)
+	return x509.ParsePKCS1PrivateKey(block.Bytes)
+}
+
+func JRDFor(username, domain string) (*webfinger.JRD, error) {
+	if _, err := user.Lookup(username); err != nil {
+		return nil, err
+	}
+	return &webfinger.JRD{
+		Subject: fmt.Sprintf("acct:%s@%s", username, domain),
+		Links: []webfinger.Link{
+			webfinger.Link{
+				Rel:  "self",
+				Type: apub.ContentType,
+				Href: fmt.Sprintf("https://%s/%s/actor.json", domain, username),
+			},
+		},
+	}, nil
+}
blob - /dev/null
blob + d1361750b4e59aef58727bc664032877b9cc84e5 (mode 644)
--- /dev/null
+++ internal/sys/user_test.go
@@ -0,0 +1,16 @@
+package sys
+
+import "testing"
+
+func TestJRD(t *testing.T) {
+	jrd, err := JRDFor("nobody", "example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if jrd.Subject != "acct:nobody@example.com" {
+		t.Errorf("unexpected subject %s", jrd.Subject)
+	}
+	if jrd.Links[0].Href != "https://example.com/nobody/actor.json" {
+		t.Errorf("unexpected href %s", jrd.Links[0].Href)
+	}
+}