Commit Diff


commit - ed0db64ab00968903e807add075cfd6dcbf1dafb
commit + d3dfb6729126922affd3865165ef240d239837e4
blob - bfcb8e5d35c75965c081cafe858dbf41b98e644d
blob + 32791cec840b2b12d1e1e8d0b65ff70e891168eb
--- client.go
+++ client.go
@@ -4,21 +4,24 @@ import (
 	"bytes"
 	"crypto/rsa"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
 	"os"
+	"path"
 	"strings"
 )
 
-var defaultClient Client = Client{Client: http.DefaultClient}
+var DefaultClient Client = Client{Client: http.DefaultClient}
 
 func Lookup(id string) (*Activity, error) {
-	return defaultClient.Lookup(id)
+	return DefaultClient.Lookup(id)
 }
 
 func LookupActor(id string) (*Actor, error) {
-	return defaultClient.LookupActor(id)
+	return DefaultClient.LookupActor(id)
 }
 
 type Client struct {
@@ -38,16 +41,10 @@ func (c *Client) Lookup(id string) (*Activity, error) 
 		c.Client = http.DefaultClient
 	}
 
-	req, err := http.NewRequest(http.MethodGet, id, nil)
+	req, err := newRequest(http.MethodGet, id, nil, c.Key, c.PubKeyID)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("new request: %w", err)
 	}
-	req.Header.Set("Accept", ContentType)
-	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)
-		}
-	}
 	resp, err := c.Do(req)
 	if err != nil {
 		return nil, err
@@ -66,7 +63,14 @@ func (c *Client) LookupActor(id string) (*Actor, error
 	if err != nil {
 		return nil, err
 	}
-	return activityToActor(activity), nil
+	switch activity.Type {
+	case "Application", "Group", "Organization", "Person", "Service":
+		return activityToActor(activity), nil
+	case "Collection", "OrderedCollection":
+		// probably followers. let caller work out what it wants to do
+		return activityToActor(activity), nil
+	}
+	return nil, fmt.Errorf("bad object Type %s", activity.Type)
 }
 
 func activityToActor(activity *Activity) *Actor {
@@ -93,14 +97,10 @@ func (c *Client) Send(inbox string, activity *Activity
 	if err != nil {
 		return nil, fmt.Errorf("encode outgoing activity: %w", err)
 	}
-	req, err := http.NewRequest(http.MethodPost, inbox, bytes.NewReader(b))
+	req, err := newRequest(http.MethodPost, inbox, bytes.NewReader(b), c.Key, c.PubKeyID)
 	if err != nil {
 		return nil, err
 	}
-	req.Header.Set("Content-Type", ContentType)
-	if err := Sign(req, c.Key, c.PubKeyID); err != nil {
-		return nil, fmt.Errorf("sign outgoing request: %w", err)
-	}
 	resp, err := c.Do(req)
 	if err != nil {
 		return nil, err
@@ -116,3 +116,20 @@ func (c *Client) Send(inbox string, activity *Activity
 		return nil, fmt.Errorf("non-ok response status %s", resp.Status)
 	}
 }
+
+func newRequest(method, url string, body io.Reader, key *rsa.PrivateKey, pubkeyURL string) (*http.Request, error) {
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", ContentType)
+	if body != nil {
+		req.Header.Set("Content-Type", ContentType)
+	}
+	if key != nil {
+		if err := Sign(req, key, pubkeyURL); err != nil {
+			return nil, fmt.Errorf("sign request: %w", err)
+		}
+	}
+	return req, nil
+}
blob - 26753957c01f35d219f7733828b2e4686ffb55a8
blob + afb03db54ceccd4fdbf4ac30487d4c608a682dc6
--- cmd/apget/apget.go
+++ cmd/apget/apget.go
@@ -23,13 +23,17 @@ import (
 	"flag"
 	"log"
 	"os"
+	"os/user"
 
 	"olowe.co/apub"
+	"olowe.co/apub/internal/sys"
 )
 
 var jflag bool
 
 func init() {
+	log.SetFlags(0)
+	log.SetPrefix("apsend: ")
 	flag.BoolVar(&jflag, "j", false, "format as json")
 	flag.Parse()
 }
@@ -40,8 +44,18 @@ func main() {
 	if len(flag.Args()) != 1 {
 		log.Fatalln("usage:", usage)
 	}
-	activity, err := apub.Lookup(flag.Args()[0])
+	current, err := user.Current()
 	if err != nil {
+		log.Fatal(err)
+	}
+	client, err := sys.ClientFor(current.Username, "apubtest2.srcbeat.com")
+	if err != nil {
+		log.Println("create activitypub client for %s: %v", current.Username, err)
+		log.Println("requests will not be signed")
+		client = &apub.DefaultClient
+	}
+	activity, err := client.Lookup(flag.Args()[0])
+	if err != nil {
 		log.Fatalf("lookup %s: %v", flag.Args()[0], err)
 	}
 	if jflag {
@@ -52,7 +66,7 @@ func main() {
 		}
 		return
 	}
-	msg, err := apub.MarshalMail(activity)
+	msg, err := apub.MarshalMail(activity, client)
 	if err != nil {
 		log.Println("marshal to mail:", err)
 	}
blob - a5fbda6ffee7f954b540793f4249cf258a9e5034
blob + 8eab875d3be01c461603b264459d99ef544b1609
--- cmd/apsend/apsend.go
+++ cmd/apsend/apsend.go
@@ -73,11 +73,18 @@ func main() {
 		os.Exit(1)
 	}
 
+	current, err := user.Current()
+	if err != nil {
+		log.Fatal(err)
+	}
+	client, err := sys.ClientFor(current.Username, sysName)
+	if err != nil {
+		log.Fatalf("apub cilent for %s: %v", current.Username, err)
+	}
+
 	var activity *apub.Activity
 	var bmsg []byte
-	var err error
 	if jflag {
-		var err error
 		activity, err = apub.Decode(os.Stdin)
 		if err != nil {
 			log.Fatalln("decode activity:", err)
@@ -91,42 +98,43 @@ func main() {
 		if err != nil {
 			log.Fatal(err)
 		}
-		activity, err = apub.UnmarshalMail(msg)
+		activity, err = apub.UnmarshalMail(msg, client)
 		if err != nil {
 			log.Fatalln("unmarshal activity from message:", err)
 		}
 	}
 
 	var remote []string
+	var gotErr bool
 	for _, rcpt := range flag.Args() {
 		if !strings.Contains(rcpt, "@") {
 			if err := deliverLocal(rcpt, bmsg); err != nil {
+				gotErr = true
 				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)
+		from, err := client.LookupActor(activity.AttributedTo)
 		if err != nil {
 			log.Fatalf("lookup actor %s: %v", activity.AttributedTo, err)
 		}
-		client, err := sys.ClientFor(from.Username, sysName)
+		// everything we do from here onwards is on behalf of the sender,
+		// so outbound requests must be signed with the sender's key.
+		client, err = sys.ClientFor(from.Username, sysName)
 		if err != nil {
-			log.Fatalf("apub cilent for %s: %v", from.Username, err)
+			log.Fatalf("activitypub client 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, err = apub.MarshalMail(activity)
+			bmsg, err = apub.MarshalMail(activity, client)
 			if err != nil {
 				log.Fatalf("remarshal %s activity to mail: %v", activity.Type, err)
 			}
@@ -141,25 +149,15 @@ func main() {
 		}
 
 		// 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)
+		if err := sys.AppendToOutbox(from.Username, activity, create); err != nil {
+			log.Fatalf("append activities to outbox: %v", 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 strings.Contains(rcpt, "+followers") {
+				rcpt = strings.Replace(rcpt, "+followers", "", 1)
+			}
+			ra, err := client.Finger(rcpt)
 			if err != nil {
 				log.Printf("webfinger %s: %v", rcpt, err)
 				gotErr = true
blob - 1f9a3ecfcdd21a6e6e0ad3bf0e48aa012eed1b50
blob + 9e65ce624b9326cd7de5e88ad494c0121aa5dc97
--- cmd/apserve/listen.go
+++ cmd/apserve/listen.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"olowe.co/apub"
+	"olowe.co/apub/internal/sys"
 )
 
 type server struct {
@@ -55,8 +56,12 @@ func (srv *server) relay(username string, activity *ap
 	}
 
 	cmd := exec.Command("apsend", username)
-	msg, err := apub.MarshalMail(activity)
+	client, err := sys.ClientFor(username, domain)
 	if err != nil {
+		log.Printf("activitypub client for %s: %v", username, err)
+	}
+	msg, err := apub.MarshalMail(activity, client)
+	if err != nil {
 		log.Printf("marshal %s %s to mail message: %v", activity.Type, activity.ID, err)
 		return
 	}
blob - 075f3ce03072e1d27562a1b4a05b35535bcdbcbb
blob + 1b28603422eb76b391a73b6fafc538ae10c496ba
--- cmd/apsubmit/server.go
+++ cmd/apsubmit/server.go
@@ -9,9 +9,10 @@ import (
 	"os"
 	"os/exec"
 	"os/user"
+	"strings"
 
 	"github.com/emersion/go-smtp"
-	"olowe.co/apub"
+	"webfinger.net/go/webfinger"
 )
 
 type Backend struct {
@@ -58,7 +59,11 @@ func (s *Session) Rcpt(to string, opts *smtp.RcptOptio
 	if err != nil {
 		return err
 	}
-	if _, err = apub.Finger(addr.Address); err != nil {
+	q := addr.Address
+	if strings.Contains(addr.Address, "+followers") {
+		q = strings.Replace(addr.Address, "+followers", "", 1)
+	}
+	if _, err := webfinger.Lookup(q, nil); err != nil {
 		return err
 	}
 	s.recipients = append(s.recipients, to)
blob - b1a14b6385df6197b110d7a1955b29bae60507b5
blob + b4b935cad9a1f8b701f55800c9c316e15e7c7059
--- internal/sys/user.go
+++ internal/sys/user.go
@@ -3,6 +3,7 @@ package sys
 import (
 	"crypto/rsa"
 	"crypto/x509"
+	"encoding/json"
 	"encoding/pem"
 	"fmt"
 	"net/http"
@@ -118,3 +119,24 @@ func JRDFor(username, domain string) (*webfinger.JRD, 
 		},
 	}, nil
 }
+
+func AppendToOutbox(username string, activities ...*apub.Activity) error {
+	u, err := user.Lookup(username)
+	if err != nil {
+		return fmt.Errorf("lookup user: %w", err)
+	}
+	outbox := path.Join(UserDataDir(u), "outbox")
+	for _, a := range activities {
+		fname := path.Base(a.ID)
+		fname = path.Join(outbox, fname)
+		f, err := os.Create(fname)
+		if err != nil {
+			return fmt.Errorf("create file for %s: %w", a.ID, err)
+		}
+		if err := json.NewEncoder(f).Encode(a); err != nil {
+			return fmt.Errorf("encode %s: %w", a.ID, err)
+		}
+		f.Close()
+	}
+	return nil
+}
blob - 1e5c0bd4dd580bc4c32e1c2d03f777ca0e989d5e
blob + b0629b9bdee6b6ad3ae1f7765b27869f29cfac28
--- mail.go
+++ mail.go
@@ -10,72 +10,111 @@ import (
 	"time"
 )
 
-func MarshalMail(activity *Activity) ([]byte, error) {
-	buf := &bytes.Buffer{}
+func MarshalMail(activity *Activity, client *Client) ([]byte, error) {
+	msg, err := marshalMail(activity, client)
+	if err != nil {
+		return nil, err
+	}
+	return encodeMsg(msg), nil
+}
 
-	from, err := LookupActor(activity.AttributedTo)
+func marshalMail(activity *Activity, client *Client) (*mail.Message, error) {
+	if client == nil {
+		client = &DefaultClient
+	}
+
+	msg := new(mail.Message)
+	msg.Header = make(mail.Header)
+	var actors []Actor
+	from, err := client.LookupActor(activity.AttributedTo)
 	if err != nil {
-		return nil, fmt.Errorf("lookup actor %s: %w", activity.AttributedTo, err)
+		return nil, fmt.Errorf("build From: lookup actor %s: %w", activity.AttributedTo, err)
 	}
-	fmt.Fprintf(buf, "From: %s\n", from.Address())
+	actors = append(actors, *from)
+	msg.Header["From"] = []string{from.Address().String()}
 
-	var rcpt []string
-	for _, u := range activity.To {
-		if u == PublicCollection {
+	var addrs, collections []string
+	for _, id := range activity.To {
+		if id == PublicCollection {
 			continue
 		}
-		actor, err := LookupActor(u)
+
+		a, err := client.LookupActor(id)
 		if err != nil {
-			return nil, fmt.Errorf("lookup actor %s: %w", u, err)
+			return nil, fmt.Errorf("build To: lookup actor %s: %w", id, err)
 		}
-		rcpt = append(rcpt, actor.Address().String())
+		if a.Type == "Collection" || a.Type == "OrderedCollection" {
+			collections = append(collections, a.ID)
+		} else {
+			addrs = append(addrs, a.Address().String())
+			actors = append(actors, *a)
+		}
 	}
-	fmt.Fprintln(buf, "To:", strings.Join(rcpt, ", "))
+	for _, id := range collections {
+		if i := indexFollowers(actors, id); i >= 0 {
+			addrs = append(addrs, actors[i].FollowersAddress().String())
+		}
+	}
+	msg.Header["To"] = addrs
 
-	var rcptcc []string
-	if activity.CC != nil {
-		for _, u := range activity.CC {
-			if u == PublicCollection {
-				continue
-			} else if u == from.Followers {
-				rcptcc = append(rcptcc, from.FollowersAddress().String())
-				continue
-			}
-			actor, err := LookupActor(u)
-			if err != nil {
-				return nil, fmt.Errorf("lookup actor %s: %w", u, err)
-			}
-			rcptcc = append(rcptcc, actor.Address().String())
+	addrs, collections = []string{}, []string{}
+	for _, id := range activity.CC {
+		if id == PublicCollection {
+			continue
 		}
-		fmt.Fprintln(buf, "CC:", strings.Join(rcptcc, ", "))
+
+		a, err := client.LookupActor(id)
+		if err != nil {
+			return nil, fmt.Errorf("build CC: lookup actor %s: %w", id, err)
+		}
+		if a.Type == "Collection" || a.Type == "OrderedCollection" {
+			collections = append(collections, a.ID)
+			continue
+		}
+		addrs = append(addrs, a.Address().String())
+		actors = append(actors, *a)
 	}
+	for _, id := range collections {
+		if i := indexFollowers(actors, id); i >= 0 {
+			addrs = append(addrs, actors[i].FollowersAddress().String())
+		}
+	}
+	msg.Header["CC"] = addrs
 
-	fmt.Fprintf(buf, "Date: %s\n", activity.Published.Format(time.RFC822))
-	fmt.Fprintf(buf, "Message-ID: <%s>\n", activity.ID)
+	msg.Header["Date"] = []string{activity.Published.Format(time.RFC822)}
+	msg.Header["Message-ID"] = []string{"<" + activity.ID + ">"}
+	msg.Header["Subject"] = []string{activity.Name}
 	if activity.Audience != "" {
-		fmt.Fprintf(buf, "List-ID: <%s>\n", activity.Audience)
+		msg.Header["List-ID"] = []string{"<" + activity.Audience + ">"}
 	}
 	if activity.InReplyTo != "" {
-		fmt.Fprintf(buf, "References: <%s>\n", activity.InReplyTo)
+		msg.Header["In-Reply-To"] = []string{"<" + activity.InReplyTo + ">"}
 	}
 
-	body := &activity.Content
+	msg.Body = strings.NewReader(activity.Content)
+	msg.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
 	if activity.Source.Content != "" && activity.Source.MediaType == "text/markdown" {
-		body = &activity.Source.Content
-		fmt.Fprintln(buf, "Content-Type: text/plain; charset=utf-8")
+		msg.Body = strings.NewReader(activity.Source.Content)
+		msg.Header["Content-Type"] = []string{"text/plain; charset=utf-8"}
 	} else if activity.MediaType == "text/markdown" {
-		fmt.Fprintln(buf, "Content-Type: text/plain; charset=utf-8")
-	} else {
-		fmt.Fprintln(buf, "Content-Type:", "text/html; charset=utf-8")
+		msg.Header["Content-Type"] = []string{"text/plain; charset=utf-8"}
 	}
-	fmt.Fprintln(buf, "Subject:", activity.Name)
-	fmt.Fprintln(buf)
-	fmt.Fprintln(buf, *body)
-	_, err = mail.ReadMessage(bytes.NewReader(buf.Bytes()))
-	return buf.Bytes(), err
+	return msg, nil
 }
 
-func UnmarshalMail(msg *mail.Message) (*Activity, error) {
+func indexFollowers(actors []Actor, id string) int {
+	for i := range actors {
+		if actors[i].Followers == id {
+			return i
+		}
+	}
+	return -1
+}
+
+func UnmarshalMail(msg *mail.Message, client *Client) (*Activity, error) {
+	if client == nil {
+		client = &DefaultClient
+	}
 	ct := msg.Header.Get("Content-Type")
 	if strings.HasPrefix(ct, "multipart") {
 		return nil, fmt.Errorf("cannot unmarshal from multipart message")
@@ -93,7 +132,7 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 	if err != nil {
 		return nil, fmt.Errorf("parse From: %w", err)
 	}
-	wfrom, err := Finger(from[0].Address)
+	wfrom, err := client.Finger(from[0].Address)
 	if err != nil {
 		return nil, fmt.Errorf("webfinger From: %w", err)
 	}
@@ -107,7 +146,7 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 		if err != nil {
 			return nil, fmt.Errorf("parse To address list: %w", err)
 		}
-		actors, err := fingerAll(to)
+		actors, err := client.fingerAll(to)
 		if err != nil {
 			return nil, fmt.Errorf("webfinger To addresses: %w", err)
 		}
@@ -127,7 +166,7 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 		if err != nil {
 			return nil, fmt.Errorf("parse CC address list: %w", err)
 		}
-		actors, err := fingerAll(cc)
+		actors, err := client.fingerAll(cc)
 		if err != nil {
 			return nil, fmt.Errorf("webfinger CC addresses: %w", err)
 		}
@@ -145,6 +184,7 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 	if _, err := io.Copy(buf, msg.Body); err != nil {
 		return nil, fmt.Errorf("read message body: %v", err)
 	}
+	content := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
 
 	return &Activity{
 		AtContext:    NormContext,
@@ -154,7 +194,7 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 		CC:           wcc,
 		MediaType:    "text/markdown",
 		Name:         strings.TrimSpace(msg.Header.Get("Subject")),
-		Content:      strings.TrimSpace(buf.String()),
+		Content:      content,
 		InReplyTo:    strings.Trim(msg.Header.Get("In-Reply-To"), "<>"),
 		Published:    &date,
 		Tag:          tags,
@@ -162,9 +202,27 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro
 }
 
 func SendMail(addr string, auth smtp.Auth, from string, to []string, activity *Activity) error {
-	msg, err := MarshalMail(activity)
+	msg, err := MarshalMail(activity, nil)
 	if err != nil {
 		return fmt.Errorf("marshal to mail message: %w", err)
 	}
 	return smtp.SendMail(addr, auth, from, to, msg)
 }
+
+func encodeMsg(msg *mail.Message) []byte {
+	buf := &bytes.Buffer{}
+	// Lead with "From", end with "Subject" to make some mail clients happy.
+	fmt.Fprintln(buf, "From:", msg.Header.Get("From"))
+	for k, v := range msg.Header {
+		switch k {
+		case "Subject", "From":
+			continue
+		default:
+			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 - fe6864206e60fa17becbdec36ffe8bd64e15b13f
blob + 23e4be152cca0e333253127592f31aa76f8346bb
--- mail_test.go
+++ mail_test.go
@@ -2,8 +2,12 @@ package apub
 
 import (
 	"bytes"
+	"errors"
 	"net/mail"
 	"os"
+	"reflect"
+	"sort"
+	"strings"
 	"testing"
 )
 
@@ -52,14 +56,41 @@ func TestMailAddress(t *testing.T) {
 }
 
 func TestMarshalMail(t *testing.T) {
-	var notes []string = []string{
-		"testdata/note/akkoma.json",
-		"testdata/note/lemmy.json",
-		"testdata/note/mastodon.json",
-		"testdata/page.json",
+	tests := []struct {
+		name       string
+		recipients []string
+	}{
+		{
+			"testdata/note/akkoma.json",
+			[]string{
+				"kariboka+followers@social.harpia.red",
+				"otl@apubtest2.srcbeat.com",
+			},
+		},
+		{
+			"testdata/note/lemmy.json",
+			[]string{
+				"Feathercrown@lemmy.world",
+				"technology@lemmy.world",
+			},
+		},
+		{
+			"testdata/note/mastodon.json",
+			[]string{
+				"otl+followers@hachyderm.io",
+				"selfhosted+followers@lemmy.world",
+				"selfhosted@lemmy.world",
+			},
+		},
+		{
+			"testdata/page.json",
+			[]string{
+				"technology@lemmy.world",
+			},
+		},
 	}
-	for _, name := range notes {
-		f, err := os.Open(name)
+	for _, tt := range tests {
+		f, err := os.Open(tt.name)
 		if err != nil {
 			t.Error(err)
 			continue
@@ -67,31 +98,53 @@ func TestMarshalMail(t *testing.T) {
 		defer f.Close()
 		a, err := Decode(f)
 		if err != nil {
-			t.Errorf("%s: decode activity: %v", name, err)
+			t.Errorf("%s: decode activity: %v", tt.name, err)
 			continue
 		}
-		b, err := MarshalMail(a)
+		b, err := MarshalMail(a, nil)
 		if err != nil {
-			t.Errorf("%s: marshal to mail message: %v", name, err)
+			t.Errorf("%s: marshal to mail message: %v", tt.name, err)
 			continue
 		}
 		msg, err := mail.ReadMessage(bytes.NewReader(b))
 		if err != nil {
-			t.Errorf("%s: read back message from marshalled activity: %v", name, err)
+			t.Errorf("%s: read back message from marshalled activity: %v", tt.name, err)
 			continue
 		}
-		p := make([]byte, 8)
-		n, err := msg.Body.Read(p)
-		if err != nil {
-			t.Errorf("%s: read message body: %v", name, err)
+		rcptto, err := msg.Header.AddressList("To")
+		if errors.Is(err, mail.ErrHeaderNotPresent) {
+			// whatever; sometimes the Activity has an empty slice.
+		} else if err != nil {
+			t.Errorf("%s: parse To addresses: %v", tt.name, err)
+			t.Log("raw value:", msg.Header.Get("To"))
+			continue
 		}
-		if n != len(p) {
+		rcptcc, err := msg.Header.AddressList("CC")
+		if errors.Is(err, mail.ErrHeaderNotPresent) {
+			// whatever; sometimes the Activity has an empty slice.
+		} else if err != nil {
+			t.Errorf("%s: parse CC addresses: %v", tt.name, err)
+			t.Log("raw value:", msg.Header.Get("CC"))
+			continue
+		}
+		t.Log(rcptto)
+		t.Log(rcptcc)
+		rcpts := make([]string, len(rcptto)+len(rcptcc))
+		for i, rcpt := range append(rcptto, rcptcc...) {
+			rcpts[i] = rcpt.Address
+		}
+		sort.Strings(rcpts)
+		if !reflect.DeepEqual(rcpts, tt.recipients) {
+			t.Errorf("%s: unexpected recipients, want %s got %s", tt.name, tt.recipients, rcpts)
+		}
+
+		p := make([]byte, 8)
+		if _, err := msg.Body.Read(p); err != nil {
+			// Pages have no content, so skip this case
 			if a.Type == "Page" {
-				// Pages have no content, so skip this case
 				continue
 			}
-			t.Errorf("%s: short read from body", name)
-			t.Log(string(p))
+			t.Errorf("%s: read message body: %v", tt.name, err)
 		}
 	}
 }
@@ -109,7 +162,7 @@ func TestUnmarshalMail(t *testing.T) {
 	if testing.Short() {
 		t.Skip("skipping network calls to unmarshal mail message to Activity")
 	}
-	a, err := UnmarshalMail(msg)
+	a, err := UnmarshalMail(msg, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -120,4 +173,19 @@ func TestUnmarshalMail(t *testing.T) {
 	if a.Tag[0].Name != want {
 		t.Errorf("wanted tag name %s, got %s", want, a.Tag[0].Name)
 	}
+	if a.MediaType != "text/markdown" {
+		t.Errorf("wrong media type: wanted %s, got %s", "text/markdown", a.MediaType)
+	}
+	wantCC := []string{
+		"https://programming.dev/c/programming",
+		"https://programming.dev/u/starman",
+		"https://hachyderm.io/users/otl/followers",
+	}
+	if !reflect.DeepEqual(wantCC, a.CC) {
+		t.Errorf("wanted recipients %s, got %s", wantCC, a.CC)
+	}
+	if strings.Contains(a.Content, "\r") {
+		t.Errorf("activity content contains carriage returns")
+	}
+	t.Log(a)
 }
blob - f872625d946401146d5e4c6104e37b8065d32476
blob + e14e2d1d30a341cb11582c2c92d946dcb6391959
--- webfinger.go
+++ webfinger.go
@@ -10,7 +10,7 @@ import (
 
 // Finger wraps defaultClient.Finger.
 func Finger(address string) (*Actor, error) {
-	return defaultClient.Finger(address)
+	return DefaultClient.Finger(address)
 }
 
 // Finger is convenience method returning the corresponding Actor,
@@ -29,7 +29,7 @@ func (c *Client) Finger(address string) (*Actor, error
 	return nil, ErrNotExist
 }
 
-func fingerAll(alist []*mail.Address) ([]Actor, error) {
+func (c *Client) fingerAll(alist []*mail.Address) ([]Actor, error) {
 	actors := make([]Actor, len(alist))
 	for i, addr := range alist {
 		q := addr.Address
@@ -37,7 +37,7 @@ func fingerAll(alist []*mail.Address) ([]Actor, error)
 			// strip "+followers" to get the regular address that can be fingered.
 			q = strings.Replace(addr.Address, "+followers", "", 1)
 		}
-		actor, err := Finger(q)
+		actor, err := c.Finger(q)
 		if err != nil {
 			return actors, fmt.Errorf("finger %s: %w", q, err)
 		}