init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
package lib
import (
"bufio"
"bytes"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
)
type Part struct {
MimeType string
Params map[string]string
Data []byte
Converted bool
ConversionError error
}
func NewPart(mimetype string, params map[string]string, body io.Reader,
) (*Part, error) {
var d []byte
var err error
var converted bool
if body == nil {
converted = true
} else {
d, err = io.ReadAll(body)
if err != nil {
return nil, err
}
}
return &Part{
MimeType: mimetype,
Params: params,
Data: d,
Converted: converted,
}, nil
}
func (p *Part) NewReader() io.Reader {
return bytes.NewReader(p.Data)
}
type Attachment interface {
Name() string
WriteTo(w *mail.Writer) error
}
type FileAttachment struct {
path string
}
func NewFileAttachment(path string) *FileAttachment {
return &FileAttachment{
path,
}
}
func (fa *FileAttachment) Name() string {
return fa.path
}
func (fa *FileAttachment) WriteTo(w *mail.Writer) error {
f, err := os.Open(xdg.ExpandHome(fa.path))
if err != nil {
return errors.Wrap(err, "os.Open")
}
defer f.Close()
reader := bufio.NewReader(f)
mimeType, params, err := FindMimeType(fa.path, reader)
if err != nil {
return errors.Wrap(err, "ParseMediaType")
}
filename := filepath.Base(fa.path)
params["name"] = filename
// set header fields
ah := mail.AttachmentHeader{}
ah.SetContentType(mimeType, params)
// setting the filename auto sets the content disposition
ah.SetFilename(filename)
fixContentTransferEncoding(mimeType, &ah)
aw, err := w.CreateAttachment(ah)
if err != nil {
return errors.Wrap(err, "CreateAttachment")
}
defer aw.Close()
if _, err := reader.WriteTo(aw); err != nil {
return errors.Wrap(err, "reader.WriteTo")
}
return nil
}
type PartAttachment struct {
part *Part
name string
}
func NewPartAttachment(part *Part, name string) *PartAttachment {
return &PartAttachment{
part,
name,
}
}
func (pa *PartAttachment) Name() string {
return pa.name
}
func (pa *PartAttachment) WriteTo(w *mail.Writer) error {
// set header fields
ah := mail.AttachmentHeader{}
ah.SetContentType(pa.part.MimeType, pa.part.Params)
// setting the filename auto sets the content disposition
ah.SetFilename(pa.Name())
fixContentTransferEncoding(pa.part.MimeType, &ah)
aw, err := w.CreateAttachment(ah)
if err != nil {
return errors.Wrap(err, "CreateAttachment")
}
defer aw.Close()
if _, err := io.Copy(aw, pa.part.NewReader()); err != nil {
return errors.Wrap(err, "io.Copy")
}
return nil
}
// SetUtf8Charset sets the charset in a params map to UTF-8.
func SetUtf8Charset(origParams map[string]string) map[string]string {
params := make(map[string]string)
for k, v := range origParams {
switch strings.ToLower(k) {
case "charset":
log.Debugf("substitute charset %s with utf-8", v)
params[k] = "utf-8"
default:
params[k] = v
}
}
return params
}
func FindMimeType(filename string, reader *bufio.Reader) (string, map[string]string, error) {
// if we have an extension, prefer that instead of trying to sniff the header.
// That's generally more accurate than sniffing as lots of things are zip files
// under the hood, e.g. most office file types
ext := filepath.Ext(filename)
var mimeString string
if mimeString = mime.TypeByExtension(ext); mimeString == "" {
// Sniff the mime type since it's not in the database
// http.DetectContentType only cares about the first 512 bytes
head, err := reader.Peek(512)
if err != nil && err != io.EOF {
return "", map[string]string{}, errors.Wrap(err, "Peek")
}
mimeString = http.DetectContentType(head)
}
// mimeString can contain type and params (like text encoding),
// so we need to break them apart before passing them to the headers
return mime.ParseMediaType(mimeString)
}
// fixContentTransferEncoding checks the mime type of the attachment and
// corrects the content-transfer-encoding if necessary.
//
// It's expressly forbidden by RFC2046 to set any other
// content-transfer-encoding than 7bit, 8bit, or binary for
// message/rfc822 mime types (see RFC2046, section 5.2.1)
func fixContentTransferEncoding(mimeType string, header *mail.AttachmentHeader) {
if strings.ToLower(mimeType) == "message/rfc822" {
header.Add("Content-Transfer-Encoding", "binary")
}
}
+147
View File
@@ -0,0 +1,147 @@
package auth
import (
"fmt"
"regexp"
"strings"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-msgauth/authres"
)
const (
AuthHeader = "Authentication-Results"
)
type Method string
const (
DKIM Method = "dkim"
SPF Method = "spf"
DMARC Method = "dmarc"
)
type Result string
const (
ResultNone Result = "none"
ResultPass Result = "pass"
ResultFail Result = "fail"
ResultNeutral Result = "neutral"
ResultPolicy Result = "policy"
)
type Details struct {
Results []Result
Infos []string
Reasons []string
Err error
}
func (d *Details) add(r Result, info string, reason string) {
d.Results = append(d.Results, r)
d.Infos = append(d.Infos, info)
d.Reasons = append(d.Reasons, reason)
}
type ParserFunc func(*mail.Header, []string) (*Details, error)
func New(s string) ParserFunc {
if i := strings.IndexRune(s, '+'); i > 0 {
s = s[:i]
}
m := Method(strings.ToLower(s))
switch m {
case DKIM, SPF, DMARC:
return CreateParser(m)
}
return nil
}
func trust(s string, trusted []string) bool {
for _, t := range trusted {
if matched, _ := regexp.MatchString(t, s); matched || t == "*" {
return true
}
}
return false
}
var cleaner = regexp.MustCompile(`(\(.*);(.*\))`)
func CreateParser(m Method) func(*mail.Header, []string) (*Details, error) {
return func(header *mail.Header, trusted []string) (*Details, error) {
details := &Details{}
found := false
hf := header.FieldsByKey(AuthHeader)
for hf.Next() {
headerText, err := hf.Text()
if err != nil {
return nil, err
}
identifier, results, err := authres.Parse(headerText)
// TODO: refactor to use errors.Is
switch {
case err != nil && err.Error() == "msgauth: unsupported version":
// Some MTA write their authres header without an identifier
// which does not conform to RFC but still exists in the wild
identifier, results, err = authres.Parse("unknown;" + headerText)
if err != nil {
return nil, err
}
case err != nil && err.Error() == "msgauth: malformed authentication method and value":
// the go-msgauth parser doesn't like semi-colons in the comments
// as a work-around we remove those
cleanHeader := cleaner.ReplaceAllString(headerText, "${1}${2}")
identifier, results, err = authres.Parse(cleanHeader)
if err != nil {
return nil, err
}
case err != nil:
return nil, err
}
// implements recommendation from RFC 7601 Sec 7.1 to
// have an explicit list of trustworthy hostnames
// before displaying AuthRes results
if !trust(identifier, trusted) {
return nil, fmt.Errorf("%s is not trusted", identifier)
}
for _, result := range results {
switch r := result.(type) {
case *authres.DKIMResult:
if m == DKIM {
info := r.Identifier
if info == "" && r.Domain != "" {
info = r.Domain
}
details.add(Result(r.Value), info, r.Reason)
found = true
}
case *authres.SPFResult:
if m == SPF {
info := r.From
if info == "" && r.Helo != "" {
info = r.Helo
}
details.add(Result(r.Value), info, r.Reason)
found = true
}
case *authres.DMARCResult:
if m == DMARC {
details.add(Result(r.Value), r.From, r.Reason)
found = true
}
}
}
}
if !found {
details.add(ResultNone, "", "")
}
return details, nil
}
}
+204
View File
@@ -0,0 +1,204 @@
package calendar
import (
"bytes"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
ics "github.com/arran4/golang-ical"
)
type Reply struct {
MimeType string
Params map[string]string
CalendarText io.ReadWriter
PlainText io.ReadWriter
Organizers []string
}
func (cr *Reply) AddOrganizer(o string) {
cr.Organizers = append(cr.Organizers, o)
}
// CreateReply parses a ics request and return a ics reply (RFC 2446, Section 3.2.3)
func CreateReply(reader io.Reader, from *mail.Address, partstat string) (*Reply, error) {
cr := Reply{
MimeType: "text/calendar",
Params: map[string]string{
"charset": "UTF-8",
"method": "REPLY",
},
CalendarText: &bytes.Buffer{},
PlainText: &bytes.Buffer{},
}
var (
status ics.ParticipationStatus
action string
)
switch partstat {
case "accept":
status = ics.ParticipationStatusAccepted
action = "accepted"
case "accept-tentative":
status = ics.ParticipationStatusTentative
action = "tentatively accepted"
case "decline":
status = ics.ParticipationStatusDeclined
action = "declined"
default:
return nil, fmt.Errorf("participation status %s is not implemented", partstat)
}
name := from.Name
if name == "" {
name = from.Address
}
fmt.Fprintf(cr.PlainText, "%s has %s this invitation.", name, action)
invite, err := parse(reader)
if err != nil {
return nil, err
}
if ok := invite.request(); !ok {
return nil, fmt.Errorf("no reply is requested")
}
// update invite as a reply
reply := invite
reply.SetMethod(ics.MethodReply)
reply.SetProductId("aerc")
// check all events
for _, vevent := range reply.Events() {
e := event{vevent}
// check if we should answer
if err := e.isReplyRequested(from.Address); err != nil {
return nil, err
}
// make sure we send our reply to the meeting organizer
if organizer := e.GetProperty(ics.ComponentPropertyOrganizer); organizer != nil {
cr.AddOrganizer(organizer.Value)
}
// update attendee participation status
e.updateAttendees(status, from.Address)
// update timestamp
e.SetDtStampTime(time.Now())
// remove any subcomponents of event
e.Components = nil
}
// keep only timezone and event components
reply.clean()
if len(reply.Events()) == 0 {
return nil, fmt.Errorf("no events to respond to")
}
if err := reply.SerializeTo(cr.CalendarText); err != nil {
return nil, err
}
return &cr, nil
}
type calendar struct {
*ics.Calendar
}
func parse(reader io.Reader) (*calendar, error) {
// fix capitalized mailto for parsing of ics file
var sb strings.Builder
_, err := io.Copy(&sb, reader)
if err != nil {
return nil, fmt.Errorf("failed to copy calendar data: %w", err)
}
re := regexp.MustCompile("MAILTO:(.+@)")
str := re.ReplaceAllString(sb.String(), "mailto:${1}")
// parse calendar
invite, err := ics.ParseCalendar(strings.NewReader(str))
if err != nil {
return nil, err
}
return &calendar{invite}, nil
}
func (cal *calendar) request() (ok bool) {
ok = false
for i := range cal.CalendarProperties {
if cal.CalendarProperties[i].IANAToken == string(ics.PropertyMethod) {
if cal.CalendarProperties[i].Value == string(ics.MethodRequest) {
ok = true
return
}
}
}
return
}
func (cal *calendar) clean() {
var clean []ics.Component
for _, comp := range cal.Components {
switch comp.(type) {
case *ics.VTimezone, *ics.VEvent:
clean = append(clean, comp)
default:
continue
}
}
cal.Components = clean
}
type event struct {
*ics.VEvent
}
func (e *event) isReplyRequested(from string) error {
var present bool = false
var rsvp bool = false
from = strings.ToLower(from)
for _, a := range e.Attendees() {
if strings.ToLower(a.Email()) == from {
present = true
if r, ok := a.ICalParameters[string(ics.ParameterRsvp)]; ok {
if len(r) > 0 && strings.ToLower(r[0]) == "true" {
rsvp = true
}
}
}
}
if !present {
return fmt.Errorf("we are not invited")
}
if !rsvp {
return fmt.Errorf("we don't have to rsvp")
}
return nil
}
func (e *event) updateAttendees(status ics.ParticipationStatus, from string) {
var clean []ics.IANAProperty
for _, prop := range e.Properties {
if prop.IANAToken == string(ics.ComponentPropertyAttendee) {
att := ics.Attendee{IANAProperty: prop}
if att.Email() != from {
continue
}
prop.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{string(status)}
delete(prop.ICalParameters, string(ics.ParameterRsvp))
}
clean = append(clean, prop)
}
e.Properties = clean
}
+60
View File
@@ -0,0 +1,60 @@
package crypto
import (
"bytes"
"io"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg"
"git.sr.ht/~rjarry/aerc/lib/crypto/pgp"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-message/mail"
)
type Provider interface {
Decrypt(io.Reader, openpgp.PromptFunction) (*models.MessageDetails, error)
Encrypt(*bytes.Buffer, []string, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error)
Sign(*bytes.Buffer, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error)
ImportKeys(io.Reader) error
Init() error
Close()
GetSignerKeyId(string) (string, error)
GetKeyId(string) (string, error)
ExportKey(string) (io.Reader, error)
}
func New() Provider {
switch config.General.PgpProvider {
case "auto":
internal := &pgp.Mail{}
if internal.KeyringExists() {
log.Debugf("internal pgp keyring exists")
return internal
}
log.Debugf("no internal pgp keyring, using system gpg")
fallthrough
case "gpg":
return &gpg.Mail{}
case "internal":
return &pgp.Mail{}
default:
return nil
}
}
func IsEncrypted(bs *models.BodyStructure) bool {
if bs == nil {
return false
}
if bs.MIMEType == "application" && bs.MIMESubType == "pgp-encrypted" {
return true
}
for _, part := range bs.Parts {
if IsEncrypted(part) {
return true
}
}
return false
}
+69
View File
@@ -0,0 +1,69 @@
package gpg
import (
"bytes"
"io"
"os/exec"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
"git.sr.ht/~rjarry/aerc/models"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-message/mail"
)
// Mail satisfies the PGPProvider interface in aerc
type Mail struct{}
func (m *Mail) Init() error {
_, err := exec.LookPath("gpg")
return err
}
func (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) {
gpgReader, err := Read(r)
if err != nil {
return nil, err
}
md := gpgReader.MessageDetails
md.SignatureValidity = models.Valid
if md.SignatureError != "" {
md.SignatureValidity = handleSignatureError(md.SignatureError)
}
return md, nil
}
func (m *Mail) ImportKeys(r io.Reader) error {
return gpgbin.Import(r)
}
func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
return Encrypt(buf, header.Header.Header, rcpts, signer)
}
func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
return Sign(buf, header.Header.Header, signer)
}
func (m *Mail) Close() {}
func (m *Mail) GetSignerKeyId(s string) (string, error) {
return gpgbin.GetPrivateKeyId(s)
}
func (m *Mail) GetKeyId(s string) (string, error) {
return gpgbin.GetKeyId(s)
}
func (m *Mail) ExportKey(k string) (io.Reader, error) {
return gpgbin.ExportPublicKey(k)
}
func handleSignatureError(e string) models.SignatureValidity {
if e == "gpg: missing public key" {
return models.UnknownEntity
}
if e == "gpg: header hash does not match actual sig hash" {
return models.MicalgMismatch
}
return models.UnknownValidity
}
+160
View File
@@ -0,0 +1,160 @@
package gpg
import (
"bytes"
"io"
"os/exec"
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/models"
)
func initGPGtest(t *testing.T) {
if _, err := exec.LookPath("gpg"); err != nil {
t.Skipf("%s", err)
}
// temp dir is automatically deleted by the test runtime
dir := t.TempDir()
t.Setenv("GNUPGHOME", dir)
t.Logf("using GNUPGHOME = %s", dir)
}
func toCRLF(s string) string {
return strings.ReplaceAll(s, "\n", "\r\n")
}
func deepEqual(t *testing.T, name string, r *models.MessageDetails, expect *models.MessageDetails) {
var resBuf bytes.Buffer
if _, err := io.Copy(&resBuf, r.Body); err != nil {
t.Fatalf("%s: io.Copy() = %v", name, err)
}
var expBuf bytes.Buffer
if _, err := io.Copy(&expBuf, expect.Body); err != nil {
t.Fatalf("%s: io.Copy() = %v", name, err)
}
if resBuf.String() != expBuf.String() {
t.Errorf("%s: MessagesDetails.Body = \n%v\n but want \n%v", name, resBuf.String(), expBuf.String())
}
if r.IsEncrypted != expect.IsEncrypted {
t.Errorf("%s: IsEncrypted = \n%v\n but want \n%v", name, r.IsEncrypted, expect.IsEncrypted)
}
if r.IsSigned != expect.IsSigned {
t.Errorf("%s: IsSigned = \n%v\n but want \n%v", name, r.IsSigned, expect.IsSigned)
}
if r.SignedBy != expect.SignedBy {
t.Errorf("%s: SignedBy = \n%v\n but want \n%v", name, r.SignedBy, expect.SignedBy)
}
if r.SignedByKeyId != expect.SignedByKeyId {
t.Errorf("%s: SignedByKeyId = \n%v\n but want \n%v", name, r.SignedByKeyId, expect.SignedByKeyId)
}
if r.SignatureError != expect.SignatureError {
t.Errorf("%s: SignatureError = \n%v\n but want \n%v", name, r.SignatureError, expect.SignatureError)
}
if r.DecryptedWith != expect.DecryptedWith {
t.Errorf("%s: DecryptedWith = \n%v\n but want \n%v", name, r.DecryptedWith, expect.DecryptedWith)
}
if r.DecryptedWithKeyId != expect.DecryptedWithKeyId {
t.Errorf("%s: DecryptedWithKeyId = \n%v\n but want \n%v", name, r.DecryptedWithKeyId, expect.DecryptedWithKeyId)
}
}
const testKeyId = `B1A8669354153B799F2217BF307215C13DF7A964`
const testPrivateKeyArmored = `-----BEGIN PGP PRIVATE KEY BLOCK-----
lQOYBF5FJf8BCACvlKhSSsv4P8C3Wbv391SrNUBtFquoMuWKtuCr/Ks6KHuofGLn
bM55uBSQp908aITBDPkaOPsQ3OvwgF7SM8bNIDVpO7FHzCEg2Ysp99iPET/+LsbY
ugc8oYSuvA5aFFIOMYbAbI+HmbIBuCs+xp0AcU1cemAPzPBDCZs4xl5Y+/ce2yQz
ZGK9O/tQQIKoBUOWLo/0byAWyD6Gwn/Le3fVxxK6RPeeizDV6VfzHLxhxBNkMgmd
QUkBkvqF154wYxhzsHn72ushbJpspKz1LQN7d5u6QOq3h2sLwcLbT457qbMfZsZs
HLhoOibOd+yJ7C6TRbbyC4sQRr+K1CNGcvhJABEBAAEAB/sGyvoOIP2uL409qreW
eteoPgmtjsR6X+m4iaW8kaxwNhO+q31KFdARLnmBNTVeem60Z1OV26F/AAUSy2yf
tkgZNIdMeHY94FxhwHjdWUzkEBdJNrcTuHLCOj9/YSAvBP09tlXPyQNujBgyb9Ug
ex+k3j1PeB6STev3s/3w3t/Ukm6GvPpRSUac1i0yazGOJhGeVjBn34vqJA+D+JxP
odlCZnBGaFlj86sQs+2qlrITGCZLeLlFGXo6GEEDipCBJ94ETcpHEEZLZxoZAcdp
9iQhCK/BNpUO7H7GRs9DxiiWgV2GAeFwgt35kIwuf9X0/3Zt/23KaW/h7xe8G+0e
C0rfBADGZt5tT+5g7vsdgMCGKqi0jCbHpeLDkPbLjlYKOiWQZntLi+i6My4hjZbh
sFpWHUfc5SqBe+unClwXKO084UIzFQU5U7v9JKP+s1lCAXf1oNziDeE8p/71O0Np
J1DQ0WdjPFPH54IzLIbpUwoqha+f/4HERo2/pyIC8RMLNVcVYwQA4o27fAyLePwp
8ZcfD7BwHoWVAoHx54jMlkFCE02SMR1xXswodvCVJQ3DJ02te6SiCTNac4Ad6rRg
bL+NO+3pMhY+wY4Q9cte/13U5DAuNFrZpgum4lxQAAKDi8YgU3uEMIzB+WEvF/6d
ALIZqEl1ASCgrnu2GqG800wyJ0PncWMEAJ8746o5PHS8NZBj7cLr5HlInGFSNaXr
aclq5/eCbwjKcAYFoHCsc0MgYFtPTtSv7QwfpGcHMujjsuSpSPkwwXHXvfKBdQoF
vBaQK4WvZ/gGM2GHH3NHf3xVlEffe0K2lvPbD7YNPnlNet2hKeF08nCVD+8Rwmzb
wCZKimA98u5kM9S0NEpvaG4gRG9lIChUaGlzIGlzIGEgdGVzdCBrZXkpIDxqb2hu
LmRvZUBleGFtcGxlLm9yZz6JAU4EEwEIADgWIQSxqGaTVBU7eZ8iF78wchXBPfep
ZAUCXkUl/wIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAwchXBPfepZF4i
B/49B7q4AfO3xHEa8LK2H+f7Mnm4dRfS2YPov2p6TRe1h2DxwpTevNQUhXw2U0nf
RIEKBAZqgb7NVktkoh0DWtKatms2yHMAS+ahlQoHb2gRgXa9M9Tq0x5u9sl0NYnx
7Wu5uu6Ybw9luPKoAfO91T0vei0p3eMn3fIV0O012ITvmgKJPppQDKFJHGZJMbVD
O4TNxP89HgyhB41RO7AZadvu73S00x2K6x+OR4s/++4Y98vScCPm3DUOXeoHXKGq
FcNYTxJL9bsE2I0uYgvJSxNoK1dVnmvxp3zzhcxAdzizgMz0ufY6YLMCjy5MDOzP
ARkmYPXdkJ6jceOIqGLUw1kqnQOYBF5FJf8BCACpsh5cyHB7eEwQvLzJVsXpTW0R
h/Fe36AwC2Vz13WeE6GFrOvw1qATvtYB1919M4B44YH9J7I5SrFZad86Aw4n5Gi0
BwLlGNa/oCMvYzlNHaTXURA271ghJqdZizqVUETj3WNoaYm4mYMfb0dcayDJvVPW
P7InzOsdIRU9WXBUSyVMxNMXccr2UvIuhdPglmVT8NtsWR+q8xBoL2Dp0ojYLVD3
MlwKe1pE5mEwasYCkWePLWyGdfDW1MhUDsPH3K1IjpPLWU9FBk8KM4z8WooY9/ky
MIyRw39MvOHGfgcFBpiZwlELNZGSFhbRun03PMk2Qd3k+0FGV1IhFAYsr7QRABEB
AAEAB/9CfgQup+2HO85WWpYAsGsRLSD5FxLpcWeTm8uPdhPksl1+gxDaSEbmJcc2
Zq6ngdgrxXUJTJYlo9JVLkplMVBJKlMqg3rLaQ2wfV98EH2h7WUrZ1yaofMe3kYB
rK/yVMcBoDx067GmryQ1W4WTPXjWA8UHdOLqfH195vorFVIR/NKCK4xTgvXpGp/L
CPdNRgUvE8Q1zLWUbHGYc7OyiIdcKZugAhZ2CTYybyIfudy4vZ6tMgW6Pm+DuXGq
p1Lc1dKnZvQCu0pyw7/0EcXamQ1ZwTJel3dZa8Yg3MRHdO37i/fPoYwilT9r51b4
IBn0nZlekq1pWbNYClrdFWWAgpbnBADKY1cyGZRcwTYWkNG03O46E3doJYmLAAD3
f/HrQplRpqBohJj5HSMAev81mXLBB5QGpv2vGzkn8H+YlxwDm+2xPgfUR28mNVSQ
DjQr1GJ7BATL/NB8HJHeNIph/MWmJkFECJCM0+24NRmTzhEUboFVlCeNkOU390fy
LOGwal1RWwQA1qXMNc8VFqOGRYP8YiS3TWjoyqog1GIw/yxTXrtnUEJA/apkzhaO
L6xKqmwY26XTaOJRVhtooYpVeMAX9Hj8xZaFQjPdggT9lpyOhAoCCdcNOXZqN+V9
KMMIZL1fGeu3U0PlV1UwXzdOR3RhiWVKXjaICIBRTiwtKIWK60aTQAMD/0JDGCAa
D2nHQz0jCXaJwe7Lc3+QpfrC0LboiYgOhKjJ1XyNJqmxQNihPfnd9zRFRvuSDyTE
qClGZmS2k1FjJalFREW/KLLJL/pgf0Fsk8i50gqcFrA1x6isAgWSJgnWjTPVKLiG
OOChBL6KzqPMC2joPIDOlyzpB4CgmOwhDIUXMXmJATYEGAEIACAWIQSxqGaTVBU7
eZ8iF78wchXBPfepZAUCXkUl/wIbDAAKCRAwchXBPfepZOtqB/9xsGEgQgm70KYI
D39H91k4ef/RlpRDY1ndC0MoPfqE03IEXTC/MjtU+ksPKEoZeQsxVaUJ2WBueI5W
GJ3Y73pOHAd7N0SyGHT5s6gK1FSx29be1qiPwUu5KR2jpm3RjgpbymnOWe4C6iiY
CFQ85IX+LzpE+p9bB02PUrmzOb4MBV6E5mg30UjXIX01+bwZq5XSB4/FaUrQOAxL
uRvVRjK0CEcFbPGIlkPSW6s4M9xCC2sQi7caFKVK6Zqf78KbOwAHqfS0x9u2jtTI
hsgCjGTIAOQ5lNwpLEMjwLias6e5sM6hcK9Wo+A9Sw23f8lMau5clOZTJeyAUAff
+5anTnUn
=gemU
-----END PGP PRIVATE KEY BLOCK-----
`
const testPublicKeyArmored = `-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGcUGPEBCACox9bw5BiN9M+1qVtU90bkHl5xzPDl8SqX/2ieYSx0ZfUpmRAH
9EbW4j54cTFM6mX18Yv2LRWQhHjzslPietJ1Lb3PGY2ffDDxJsq/uQHK/ztqePc7
omJJjUuF5D7BjuOq/MFyu7dWSCXOrj8soY9HIS96pPNTF9ykLDhqKWIqGA7pORKk
RFczMLmEojLKefHvgtp9ikNNbIJyq/P5hNHr/DfC7rFaMTrXNc2xP2MD7MYNdVmT
N2NN/X676rTsu8ltUi96F5PR33mGez6Z66yMjJf863bd+muq8552ExoQGQ/uGo5y
wvwoEOF7hx1Z6JYl56hAICXPL/ZOZTPdBf+9ABEBAAG0NEphbmUgRG9lIChUaGlz
IGlzIGEgdGVzdCBrZXkpIDxqYW5lLmRvZUBleGFtcGxlLm9yZz6JAVEEEwEIADsW
IQSoQ3iEudN9vdxgn6xy8nGZUc/d5AUCZxQY8QIbAwULCQgHAgIiAgYVCgkICwIE
FgIDAQIeBwIXgAAKCRBy8nGZUc/d5ConB/9Z39ufzGmplm0m9ylN+x8iNYJJ5rk6
WhnwDsKSEDPoYnSUuESQ7zxhPkqr2amgAcFWba6vm+GvdFBB+y8JzSGIBmNmQfuw
dtBd5EI+cTSTzuXo4NXR7TrMJGPP8IvJNSrliG61JnW3kcz9U9dywum+XF57+2X1
KCt3npJI64sMX39QZ1ReaRbKWrKcBdCWZqW79KbFn4yl4ooMS9aKggQQP91feMA9
dP3onL+TWLRKVMQ657OngTKi8rIez+RasRmVV3Av+GMl0Tdcg3sWHrlliBexmC/X
mHzbl/PR8HAjWxie+pObGPz1aodJpeI0Lr5LQgJxZtx49kov9Ua5xVUxuQENBGcU
GPEBCACmVEII6Igka7AVqCrUrdRonSzuelT6X6/VToBoJMER7q5MENtqWd0iby4N
kIJxaJQFyXY7mYyZqf2aRbCu+cvh/F77iSZEOzNoJuut5sjPg7MM+s/9GRlYboq9
RGqDJwoT7+k6cdUJON5UPvdJj8GnFGGu9ZFs/cOz2psggzfeV4YbTKXzFm2yKMpx
LdeBeLXLYG46d0ChZMmKyBLLJWtUb71MU2TTWyrmtDoN02bxDQpAeJu+3Qp6lq+/
CGe5f407jkx2PDKvV6HkuYzjs8apVFVZsBkDlhkaX5YdFI2r1TxIbxC9k2UG9VLJ
lGNeqO3iUCsjuKd7iaiLGGBIeqKnABEBAAGJATYEGAEIACAWIQSoQ3iEudN9vdxg
n6xy8nGZUc/d5AUCZxQY8QIbDAAKCRBy8nGZUc/d5OxbB/sEqrdtCMFrXLOU7dur
or1lfrlYaOIaOup+/SnTSi688O0ixZ2XjV7CW3z1E8JjWAVsQPdfpC2QOZATWZ/q
ZMuEMwNpzhCVZDwBJR7nw+Pv/xFv9DvLEiJYHCyBrQtQ6vopG0t2yxJ4R/R48fQC
m2xT54mb4flIV/C8zRy3eK2wY/kR5FVxnLwwFlYayR7+wuLTiHqqxRyeZA3hQcF3
YDOgvRu3YzmESPtIBI6iNphfSSAAtkUqNJnwPAIxyky8xEInUZ7maOADRWgEH8uG
+1FjPta6cgZ1tJzFtJ7Bwa2///UAp7BQqDl7DyMQAfOZGkUI9mqEXdra4YqMv5X0
Y2UQ
=QL1U
-----END PGP PUBLIC KEY BLOCK-----
`
const testOwnertrust = "B1A8669354153B799F2217BF307215C13DF7A964:6:\n"
+35
View File
@@ -0,0 +1,35 @@
package gpgbin
import (
"bytes"
"errors"
"io"
"git.sr.ht/~rjarry/aerc/models"
)
// Decrypt runs gpg --decrypt on the contents of r. If the packet is signed,
// the signature is also verified
func Decrypt(r io.Reader) (*models.MessageDetails, error) {
md := new(models.MessageDetails)
orig, err := io.ReadAll(r)
if err != nil {
return md, err
}
args := []string{"--decrypt"}
g := newGpg(bytes.NewReader(orig), args)
_ = g.cmd.Run()
// Always parse stdout, even if there was an error running command.
// We'll find the error in the parsing
err = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md)
if errors.Is(err, NoValidOpenPgpData) {
md.Body = bytes.NewReader(orig)
return md, nil
} else if err != nil {
return nil, err
}
md.Body = bytes.NewReader(g.stdout.Bytes())
return md, nil
}
+33
View File
@@ -0,0 +1,33 @@
package gpgbin
import (
"bytes"
"fmt"
"io"
"git.sr.ht/~rjarry/aerc/models"
)
// Encrypt runs gpg --encrypt [--sign] -r [recipient]
func Encrypt(r io.Reader, to []string, from string) ([]byte, error) {
args := []string{
"--armor",
}
if from != "" {
args = append(args, "--sign", "--default-key", from)
}
for _, rcpt := range to {
args = append(args, "--recipient", rcpt)
}
args = append(args, "--encrypt", "-")
g := newGpg(r, args)
_ = g.cmd.Run()
var md models.MessageDetails
err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md)
if err != nil {
return nil, fmt.Errorf("gpg: failure to encrypt: %w. check public key(s)", err)
}
return g.stdout.Bytes(), nil
}
+261
View File
@@ -0,0 +1,261 @@
package gpgbin
import (
"bufio"
"bytes"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pinentry"
"git.sr.ht/~rjarry/aerc/models"
)
// gpg represents a gpg command with buffers attached to stdout and stderr
type gpg struct {
cmd *exec.Cmd
stdout bytes.Buffer
stderr bytes.Buffer
}
// newGpg creates a new gpg command with buffers attached
func newGpg(stdin io.Reader, args []string) *gpg {
g := new(gpg)
g.cmd = exec.Command("gpg", "--status-fd", "2", "--log-file", "/dev/null", "--batch")
g.cmd.Args = append(g.cmd.Args, args...)
g.cmd.Stdin = stdin
g.cmd.Stdout = &g.stdout
g.cmd.Stderr = &g.stderr
pinentry.SetCmdEnv(g.cmd)
return g
}
// fields returns the field name from --status-fd output. See:
// https://github.com/gpg/gnupg/blob/master/doc/DETAILS
func field(s string) string {
tokens := strings.SplitN(s, " ", 3)
if tokens[0] == "[GNUPG:]" {
return tokens[1]
}
return ""
}
// getIdentity returns the identity of the given key
func getIdentity(key uint64) string {
fpr := fmt.Sprintf("%X", key)
cmd := exec.Command("gpg", "--with-colons", "--batch", "--list-keys", fpr)
var outbuf strings.Builder
cmd.Stdout = &outbuf
err := cmd.Run()
if err != nil {
log.Errorf("gpg: failed to get identity: %v", err)
return ""
}
out := strings.Split(outbuf.String(), "\n")
for _, line := range out {
if strings.HasPrefix(line, "uid") {
flds := strings.Split(line, ":")
return flds[9]
}
}
return ""
}
// getKeyId returns the 16 digit key id, if key exists
func getKeyId(s string, private bool) string {
cmd := exec.Command("gpg", "--with-colons", "--batch")
listArg := "--list-keys"
if private {
listArg = "--list-secret-keys"
}
cmd.Args = append(cmd.Args, listArg, s)
var outbuf strings.Builder
cmd.Stdout = &outbuf
err := cmd.Run()
if err != nil {
log.Errorf("gpg: failed to get key ID: %v", err)
return ""
}
out := strings.Split(outbuf.String(), "\n")
for _, line := range out {
if strings.HasPrefix(line, "fpr") {
flds := strings.Split(line, ":")
id := flds[9]
return id[len(id)-16:]
}
}
return ""
}
// longKeyToUint64 returns a uint64 version of the given key
func longKeyToUint64(key string) (uint64, error) {
fpr := string(key[len(key)-16:])
fprUint64, err := strconv.ParseUint(fpr, 16, 64)
if err != nil {
return 0, err
}
return fprUint64, nil
}
// parse parses the output of gpg --status-fd
func parseStatusFd(r io.Reader, md *models.MessageDetails) error {
var err error
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if field(line) == "PLAINTEXT_LENGTH" {
continue
}
log.Tracef(line)
switch field(line) {
case "ENC_TO":
md.IsEncrypted = true
case "DECRYPTION_KEY":
md.DecryptedWithKeyId, err = parseDecryptionKey(line)
md.DecryptedWith = getIdentity(md.DecryptedWithKeyId)
if err != nil {
return err
}
case "DECRYPTION_FAILED":
return EncryptionFailed
case "NEWSIG":
md.IsSigned = true
case "GOODSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignedBy = t[3]
case "BADSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: invalid signature"
md.SignedBy = t[3]
case "EXPSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: expired signature"
md.SignedBy = t[3]
case "EXPKEYSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: signature made with expired key"
md.SignedBy = t[3]
case "REVKEYSIG":
t := strings.SplitN(line, " ", 4)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
md.SignatureError = "gpg: signature made with revoked key"
md.SignedBy = t[3]
case "ERRSIG":
t := strings.SplitN(line, " ", 9)
md.SignedByKeyId, err = longKeyToUint64(t[2])
if err != nil {
return err
}
if t[7] == "9" {
md.SignatureError = "gpg: missing public key"
}
if t[7] == "4" {
md.SignatureError = "gpg: unsupported algorithm"
}
md.SignedBy = "(unknown signer)"
case "INV_RECP":
t := strings.SplitN(line, " ", 4)
if t[2] == "10" {
return fmt.Errorf("gpg: public key of %s is not trusted", t[3])
}
case "SIG_CREATED":
fields := strings.Split(line, " ")
micalg, err := strconv.Atoi(fields[4])
if err != nil {
return MicalgNotFound
}
md.Micalg = micalgs[micalg]
case "VALIDSIG":
fields := strings.Split(line, " ")
micalg, err := strconv.Atoi(fields[9])
if err != nil {
return MicalgNotFound
}
md.Micalg = micalgs[micalg]
case "NODATA":
t := strings.SplitN(line, " ", 3)
if t[2] == "4" {
md.SignatureError = "gpg: no signature packet found"
}
if t[2] == "1" {
return NoValidOpenPgpData
}
case "FAILURE":
return fmt.Errorf("%s", strings.TrimPrefix(line, "[GNUPG:] "))
}
}
return nil
}
// parseDecryptionKey returns primary key from DECRYPTION_KEY line
func parseDecryptionKey(l string) (uint64, error) {
key := strings.Split(l, " ")[3]
fpr := string(key[len(key)-16:])
fprUint64, err := longKeyToUint64(fpr)
if err != nil {
return 0, err
}
getIdentity(fprUint64)
return fprUint64, nil
}
type StatusFdParsingError int32
const (
EncryptionFailed StatusFdParsingError = iota + 1
MicalgNotFound
NoValidOpenPgpData
)
func (err StatusFdParsingError) Error() string {
switch err {
case EncryptionFailed:
return "gpg: decryption failed"
case MicalgNotFound:
return "gpg: micalg not found"
case NoValidOpenPgpData:
return "gpg: no valid OpenPGP data found"
default:
return "gpg: unknown status fd parsing error"
}
}
// micalgs represent hash algorithms for signatures. These are ignored by many
// email clients, but can be used as an additional verification so are sent.
// Both gpgmail and pgpmail implementations in aerc check for matching micalgs
var micalgs = map[int]string{
1: "pgp-md5",
2: "pgp-sha1",
3: "pgp-ripemd160",
8: "pgp-sha256",
9: "pgp-sha384",
10: "pgp-sha512",
11: "pgp-sha224",
}
@@ -0,0 +1,16 @@
package gpgbin
import (
"io"
)
// Import runs gpg --import-ownertrust and thus imports trusts for keys
func ImportOwnertrust(r io.Reader) error {
args := []string{"--import-ownertrust"}
g := newGpg(r, args)
err := g.cmd.Run()
if err != nil {
return err
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package gpgbin
import (
"io"
)
// Import runs gpg --import and thus imports both private and public keys
func Import(r io.Reader) error {
args := []string{"--import"}
g := newGpg(r, args)
err := g.cmd.Run()
if err != nil {
return err
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package gpgbin
import (
"bytes"
"fmt"
"io"
"os/exec"
"strings"
)
// GetPrivateKeyId runs gpg --list-secret-keys s
func GetPrivateKeyId(s string) (string, error) {
private := true
id := getKeyId(s, private)
if id == "" {
return "", fmt.Errorf("no private key found")
}
return id, nil
}
// GetKeyId runs gpg --list-keys s
func GetKeyId(s string) (string, error) {
private := false
id := getKeyId(s, private)
if id == "" {
return "", fmt.Errorf("no public key found")
}
return id, nil
}
// ExportPublicKey exports the public key identified by k in armor format
func ExportPublicKey(k string) (io.Reader, error) {
cmd := exec.Command("gpg", "--armor",
"--export-options", "export-minimal", "--export", k)
var outbuf bytes.Buffer
var stderr strings.Builder
cmd.Stdout = &outbuf
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("gpg: export failed: %w", err)
}
if strings.Contains(stderr.String(), "gpg") {
return nil, fmt.Errorf("gpg: error exporting key")
}
return &outbuf, nil
}
+29
View File
@@ -0,0 +1,29 @@
package gpgbin
import (
"bytes"
"fmt"
"io"
"git.sr.ht/~rjarry/aerc/models"
)
// Sign creates a detached signature based on the contents of r
func Sign(r io.Reader, from string) ([]byte, string, error) {
args := []string{
"--armor",
"--detach-sign",
"--default-key", from,
}
g := newGpg(r, args)
_ = g.cmd.Run()
var md models.MessageDetails
err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md)
if err != nil {
return nil, "", fmt.Errorf("failed to parse messagedetails: %w", err)
}
return g.stdout.Bytes(), md.Micalg, nil
}
+39
View File
@@ -0,0 +1,39 @@
package gpgbin
import (
"bytes"
"io"
"os"
"git.sr.ht/~rjarry/aerc/models"
)
// Verify runs gpg --verify. If s is not nil, then gpg interprets the
// arguments as a detached signature
func Verify(m io.Reader, s io.Reader) (*models.MessageDetails, error) {
args := []string{"--verify"}
if s != nil {
// Detached sig, save the sig to a tmp file and send msg over stdin
sig, err := os.CreateTemp("", "sig")
if err != nil {
return nil, err
}
_, _ = io.Copy(sig, s)
sig.Close()
defer os.Remove(sig.Name())
args = append(args, sig.Name(), "-")
}
orig, err := io.ReadAll(m)
if err != nil {
return nil, err
}
g := newGpg(bytes.NewReader(orig), args)
_ = g.cmd.Run()
md := new(models.MessageDetails)
_ = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md)
md.Body = bytes.NewReader(orig)
return md, nil
}
+169
View File
@@ -0,0 +1,169 @@
// reader.go largerly mimics github.com/emersion/go-gpgmail, with changes made
// to interface with the gpg package in aerc
package gpg
import (
"bufio"
"bytes"
"fmt"
"io"
"mime"
"strings"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
"git.sr.ht/~rjarry/aerc/lib/pinentry"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/textproto"
)
type Reader struct {
Header textproto.Header
MessageDetails *models.MessageDetails
}
func NewReader(h textproto.Header, body io.Reader) (*Reader, error) {
t, params, err := mime.ParseMediaType(h.Get("Content-Type"))
if err != nil {
return nil, err
}
if strings.EqualFold(t, "multipart/encrypted") && strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
mr := textproto.NewMultipartReader(body, params["boundary"])
return newEncryptedReader(h, mr)
}
if strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") {
micalg := params["micalg"]
mr := textproto.NewMultipartReader(body, params["boundary"])
return newSignedReader(h, mr, micalg)
}
var headerBuf bytes.Buffer
_ = textproto.WriteHeader(&headerBuf, h)
return &Reader{
Header: h,
MessageDetails: &models.MessageDetails{
Body: io.MultiReader(&headerBuf, body),
},
}, nil
}
func Read(r io.Reader) (*Reader, error) {
br := bufio.NewReader(r)
h, err := textproto.ReadHeader(br)
if err != nil {
return nil, err
}
return NewReader(h, br)
}
func newEncryptedReader(h textproto.Header, mr *textproto.MultipartReader) (*Reader, error) {
p, err := mr.NextPart()
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read first part in multipart/encrypted message: %w", err)
}
t, _, err := mime.ParseMediaType(p.Header.Get("Content-Type"))
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of first part in multipart/encrypted message: %w", err)
}
if !strings.EqualFold(t, "application/pgp-encrypted") {
return nil, fmt.Errorf("gpgmail: first part in multipart/encrypted message has type %q, not application/pgp-encrypted", t)
}
metadata, err := textproto.ReadHeader(bufio.NewReader(p))
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to parse application/pgp-encrypted part: %w", err)
}
if s := metadata.Get("Version"); s != "1" {
return nil, fmt.Errorf("gpgmail: unsupported PGP/MIME version: %q", s)
}
p, err = mr.NextPart()
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read second part in multipart/encrypted message: %w", err)
}
t, _, err = mime.ParseMediaType(p.Header.Get("Content-Type"))
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of second part in multipart/encrypted message: %w", err)
}
if !strings.EqualFold(t, "application/octet-stream") {
return nil, fmt.Errorf("gpgmail: second part in multipart/encrypted message has type %q, not application/octet-stream", t)
}
pinentry.Enable()
defer pinentry.Disable()
md, err := gpgbin.Decrypt(p)
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", err)
}
cleartext := bufio.NewReader(md.Body)
cleartextHeader, err := textproto.ReadHeader(cleartext)
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read encrypted header: %w", err)
}
t, params, err := mime.ParseMediaType(cleartextHeader.Get("Content-Type"))
if err != nil {
return nil, err
}
if md.IsEncrypted && !md.IsSigned && strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") {
// RFC 1847 encapsulation, see RFC 3156 section 6.1
micalg := params["micalg"]
mr := textproto.NewMultipartReader(cleartext, params["boundary"])
mds, err := newSignedReader(cleartextHeader, mr, micalg)
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read encapsulated multipart/signed message: %w", err)
}
mds.MessageDetails.IsEncrypted = md.IsEncrypted
mds.MessageDetails.DecryptedWith = md.DecryptedWith
mds.MessageDetails.DecryptedWithKeyId = md.DecryptedWithKeyId
return mds, nil
}
var headerBuf bytes.Buffer
_ = textproto.WriteHeader(&headerBuf, cleartextHeader)
md.Body = io.MultiReader(&headerBuf, cleartext)
return &Reader{
Header: h,
MessageDetails: md,
}, nil
}
func newSignedReader(h textproto.Header, mr *textproto.MultipartReader, micalg string) (*Reader, error) {
micalg = strings.ToLower(micalg)
p, err := mr.NextPart()
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read signed part in multipart/signed message: %w", err)
}
var headerBuf bytes.Buffer
_ = textproto.WriteHeader(&headerBuf, p.Header)
var msg bytes.Buffer
headerRdr := bytes.NewReader(headerBuf.Bytes())
fullMsg := io.MultiReader(headerRdr, p)
_, _ = io.Copy(&msg, fullMsg)
sig, err := mr.NextPart()
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read pgp part in multipart/signed message: %w", err)
}
md, err := gpgbin.Verify(&msg, sig)
if err != nil {
return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", err)
}
if md.Micalg != micalg && md.SignatureError == "" {
md.SignatureError = "gpg: header hash does not match actual sig hash"
}
return &Reader{
Header: h,
MessageDetails: md,
}, nil
}
+337
View File
@@ -0,0 +1,337 @@
package gpg
import (
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
"git.sr.ht/~rjarry/aerc/models"
)
func importSecretKey() {
r := strings.NewReader(testPrivateKeyArmored)
gpgbin.Import(r)
}
func importPublicKey() {
r := strings.NewReader(testPublicKeyArmored)
gpgbin.Import(r)
}
func importOwnertrust() {
r := strings.NewReader(testOwnertrust)
gpgbin.ImportOwnertrust(r)
}
type readerTestCase struct {
name string
want models.MessageDetails
input string
}
func TestReader(t *testing.T) {
initGPGtest(t)
importSecretKey()
importOwnertrust()
testCases := []readerTestCase{
{
name: "Encrypted and Signed",
input: testPGPMIMEEncryptedSigned,
want: models.MessageDetails{
IsEncrypted: true,
IsSigned: true,
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
SignedByKeyId: 3490876580878068068,
SignatureValidity: 0,
SignatureError: "",
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
DecryptedWithKeyId: 3490876580878068068,
Body: strings.NewReader(testEncryptedBody),
Micalg: "pgp-sha512",
},
},
{
name: "Encrypted but not signed",
input: testPGPMIMEEncryptedButNotSigned,
want: models.MessageDetails{
IsEncrypted: true,
IsSigned: false,
SignatureValidity: 0,
SignatureError: "",
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
DecryptedWithKeyId: 3490876580878068068,
Body: strings.NewReader(testEncryptedButNotSignedBody),
Micalg: "pgp-sha512",
},
},
{
name: "Signed",
input: testPGPMIMESigned,
want: models.MessageDetails{
IsEncrypted: false,
IsSigned: true,
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
SignedByKeyId: 3490876580878068068,
SignatureValidity: 0,
SignatureError: "",
DecryptedWith: "",
DecryptedWithKeyId: 0,
Body: strings.NewReader(testSignedBody),
Micalg: "pgp-sha256",
},
},
{
name: "Encapsulated Signature",
input: testPGPMIMEEncryptedSignedEncapsulated,
want: models.MessageDetails{
IsEncrypted: true,
IsSigned: true,
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
SignedByKeyId: 3490876580878068068,
SignatureValidity: 0,
SignatureError: "",
DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>",
DecryptedWithKeyId: 3490876580878068068,
Body: strings.NewReader(testSignedBody),
},
},
{
name: "Invalid Signature",
input: testPGPMIMESignedInvalid,
want: models.MessageDetails{
IsEncrypted: false,
IsSigned: true,
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
SignedByKeyId: 3490876580878068068,
SignatureValidity: 0,
SignatureError: "gpg: invalid signature",
DecryptedWith: "",
DecryptedWithKeyId: 0,
Body: strings.NewReader(testSignedInvalidBody),
Micalg: "",
},
},
{
name: "Plain text",
input: testPlaintext,
want: models.MessageDetails{
IsEncrypted: false,
IsSigned: false,
Body: strings.NewReader(testPlaintext),
},
},
}
for _, tc := range testCases {
t.Logf("Test case: %s", tc.name)
sr := strings.NewReader(tc.input)
r, err := Read(sr)
if err != nil {
t.Fatalf("gpg.Read() = %v", err)
}
deepEqual(t, tc.name, r.MessageDetails, &tc.want)
}
}
var testEncryptedBody = toCRLF(`Content-Type: text/plain
This is an encrypted message!
`)
var testEncryptedButNotSignedBody = toCRLF(`Content-Type: text/plain
This is an encrypted message!
[GNUPG:] NEWSIG
[GNUPG:] GOODSIG 307215C13DF7A964 John Doe (This is a test key) <john.doe@example.org>
It is unsigned but it will appear as signed due to the lines above!
`)
var testSignedBody = toCRLF(`Content-Type: text/plain
This is a signed message!
`)
var testSignedInvalidBody = toCRLF(`Content-Type: text/plain
This is a signed message, but the signature is invalid.
`)
var testPGPMIMEEncryptedSigned = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: multipart/encrypted; boundary=foo;
protocol="application/pgp-encrypted"
--foo
Content-Type: application/pgp-encrypted
Version: 1
--foo
Content-Type: application/octet-stream
-----BEGIN PGP MESSAGE-----
hQEMAxF0jxulHQ8+AQf/SBK2FIIgMA4OkCvlqty/1GmAumWq6J0T+pRLppXHvYFb
jbXRzz2h3pE/OoouI6vWzBwb8xU/5f8neen+fvdsF1N6PyLjZcHRB91oPvP8TuHA
0vEpiQDbP+0wlQ8BmMnnV06HokWJoKXGmIle0L4QszT/QCbrT80UgKrqXNVHKQtN
DUcytFsUCmolZRj074FEpEetjH6QGEX5hAYNBUJziXmOv7vdd4AFgNbbgC5j5ezz
h8tCAKUqeUiproYaAMrI0lfqh/t8bacJNkljI2LOxYfdJ/2317Npwly0OqpCM3YT
Q4dHuuGM6IuZHtIc9sneIBRhKf8WnWt14hLkHUT80dLA/AHKl0jGYqO34Dxd9JNB
EEwQ4j6rxauOEbKLAuYYaEqCzNYBasBrPmpNb4Fx2syWkCoYzwvzv7nj4I8vIBmm
FGsAQLX4c18qtZI4XaG4FPUvFQ01Y0rjTxAV3u51lrYjCxFuI5ZEtiT0J/Tv2Unw
R6xwtARkEf3W0agegmohEjjkAexKNxGrlulLiPk2j9/dnlAxeGpOuhYuYU2kYbKq
x3TkcVYRs1FkmCX0YHNJ2zVWLfDYd2f3UVkXINe7mODGx2A2BxvK9Ig7NMuNmWZE
ELiLSIvQk9jlgqWUMwSGPQKaHPrac02EjcBHef2zCoFbTg0TXQeDr5SV7yguX8jB
zZnoNs+6+GR1gA6poKzFdiG4NRr0SNgEHazPPkXp3P2KyOINyFJ7SA+HX8iegTqL
CTPYPK7UNRmb5s2u5B4e9NiQB9L85W4p7p7uemCSu9bxjs8rkCJpvx9Kb8jzPW17
wnEUe10A4JNDBhxiMg+Fm5oM2VxQVy+eDVFOOq7pDYVcSmZc36wO+EwAKph9shby
O4sDS4l/8eQTEYUxTavdtQ9O9ZMXvf/L3Rl1uFJXw1lFwPReXwtpA485e031/A==
=P0jf
-----END PGP MESSAGE-----
--foo--
`)
var testPGPMIMEEncryptedButNotSigned = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: multipart/encrypted; boundary=foo;
protocol="application/pgp-encrypted"
--foo
Content-Type: application/pgp-encrypted
Version: 1
--foo
Content-Type: application/octet-stream
-----BEGIN PGP MESSAGE-----
hQEMAxF0jxulHQ8+AQf9HTht3ottGv3EP/jJTI6ZISyjhul9bPNVGgCNb4Wy3IuM
fYC8EEC5VV9A0Wr8jBGcyt12iNCJCorCud5OgYjpfrX4KeWbj9eE6SZyUskbuWtA
g/CHGvheYEN4+EFMC5XvM3xlj40chMpwqs+pBHmDjJAAT8aATn1kLTzXBADBhXdA
xrsRB2o7yfLbnY8wcF9HZRK4NH4DgEmTexmUR8WdS4ASe6MK5XgNWqX/RFJzTbLM
xdR5wBovQnspVt2wzoWxYdWhb4N2NgjbslHmviNmDwrYA0hHg8zQaSxKXxvWPcuJ
Oe9JqC20C2BUeIx03srNvF3pEL+MCyZnFBEtiDvoRdLAQgES23MWuKhouywlpzaF
Gl4wqTZQC7ulThqq887zC1UaMsvVDmeub5UdK803iOywjfch2CoPE6DsUwpiAZZ1
U7yS04xttrmKqmEOLrA5SJNn9SfB7Ilz4BUaUDcWMDwhLTL0eBsvFFEXSdALg3jA
3tTAqA8D2WM0y84YCgZPFzns6MVv+oeCc2W9eDMS3DZ/qg5llaXIulOiHw5R255g
yMoJ1gzo7DMHfT/cL7eTbW7OUUvo94h3EmSojDhjeiRCFpZ8wC1BcHzWn+FLsum4
lrnUpgKI5tQjyiu0bvS1ZSCGtOPIvx7MYt5m/C91Qtp3psHdMjoHH6SvLRbbliwG
mgyp3g==
=aoPf
-----END PGP MESSAGE-----
--foo--
`)
var testPGPMIMEEncryptedSignedEncapsulated = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: multipart/encrypted; boundary=foo;
protocol="application/pgp-encrypted"
--foo
Content-Type: application/pgp-encrypted
Version: 1
--foo
Content-Type: application/octet-stream
-----BEGIN PGP MESSAGE-----
hQEMAxF0jxulHQ8+AQf9FCth8p+17rzWL0AtKP+aWndvVUYmaKiUZd+Ya8D9cRnc
FAP//JnRvTPhdOyl8x1FQkVxyuKcgpjaClb6/OLgD0lGYLC15p43G4QyU+jtOOQW
FFjZj2z8wUuiev8ejNd7DMiOQRSm4d+IIK+Qa2BJ10Y9AuLQtMI8D+joP1D11NeX
4FO3SYFEuwH5VWlXGo3bRjg8fKFVG/r/xCwBibqRpfjVnS4EgI04XCsnhqdaCRvE
Bw2XEaF62m2MUNbaan410WajzVSbSIqIHw8U7vpR/1nisS+SZmScuCXWFa6W9YgR
0nSWi1io2Ratf4F9ORCy0o7QPh7FlpsIUGmp4paF39LpAQ2q0OUnFhkIdLVQscQT
JJXLbZwp0CYTAgqwdRWFwY7rEPm2k/Oe4cHKJLEn0hS+X7wch9FAYEMifeqa0FcZ
GjxocAlyhmlM0sXIDYP8xx49t4O8JIQU1ep/SX2+rUAKIh2WRdYDy8GrrHba8V8U
aBCU9zIMhmOtu7r+FE1djMUhcaSbbvC9zLDMLV8QxogGhxrqaUM8Pj+q1H6myaAr
o1xd65b6r2Bph6GUmcMwl28i78u9bKoM0mI+EdUuLwS9EbmjtIwEgxNv4LqK8xw2
/tjCe9JSqg+HDaBYnO4QTM29Y+PltRIe6RxpnBcYULTLcSt1UK3YV1KvhqfXMjoZ
THsvtxLbmPYFv+g0hiUpuKtyG9NGidKCxrjvNq30KCSUWzNFkh+qv6CPm26sXr5F
DTsVpFTM/lomg4Po8sE20BZsk/9IzEh4ERSOu3k0m3mI4QAyJmrOpVGUjd//4cqz
Zhhc3tV78BtEYNh0a+78fAHGtdLocLj5IfOCYQWW//EtOY93TnVAtP0puaiNOc8q
Vvb5WMamiRJZ9nQXP3paDoqD14B9X6bvNWsDQDkkrWls2sYg7KzqpOM/nlXLBKQd
Ok4EJfOpd0hICPwo6tJ6sK2meRcDLxtGJybADE7UHJ4t0SrQBfn/sQhRytQtg2wr
U1Thy6RujlrrrdUryo3Mi+xc9Ot1o35JszCjNQGL6BCFsGi9fx5pjWM+lLiJ15aJ
jh02mSd/8j7IaJCGgTuyq6uK45EoVqWd1WRSYl4s5tg1g1jckigYYjJdAKNnU/rZ
iTk5F8GSyv30EXnqvrs=
=Ibxd
-----END PGP MESSAGE-----
--foo--
`)
var testPGPMIMESigned = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256;
protocol="application/pgp-signature"
--bar
Content-Type: text/plain
This is a signed message!
--bar
Content-Type: application/pgp-signature
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33
qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea
7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ
e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz
kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7
Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1
HVHFb3vTjd71z9j5IGQQ3Awdw30zMg==
=gOul
-----END PGP SIGNATURE-----
--bar--
`)
var testPGPMIMESignedInvalid = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256;
protocol="application/pgp-signature"
--bar
Content-Type: text/plain
This is a signed message, but the signature is invalid.
--bar
Content-Type: application/pgp-signature
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33
qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea
7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ
e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz
kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7
Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1
HVHFb3vTjd71z9j5IGQQ3Awdw30zMg==
=gOul
-----END PGP SIGNATURE-----
--bar--
`)
var testPlaintext = toCRLF(`From: John Doe <john.doe@example.org>
To: John Doe <john.doe@example.org>
Mime-Version: 1.0
Content-Type: text/plain
This is a plaintext message!
`)
+221
View File
@@ -0,0 +1,221 @@
// writer.go largerly mimics github.com/emersion/go-pgpmail, with changes made
// to interface with the gpg package in aerc
package gpg
import (
"bufio"
"bytes"
"fmt"
"io"
"mime"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
"git.sr.ht/~rjarry/aerc/lib/pinentry"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
"github.com/emersion/go-message/textproto"
)
type EncrypterSigner struct {
msgBuf bytes.Buffer
encryptedWriter io.Writer
to []string
from string
}
func (es *EncrypterSigner) Write(p []byte) (int, error) {
return es.msgBuf.Write(p)
}
func (es *EncrypterSigner) Close() (err error) {
pinentry.Enable()
defer pinentry.Disable()
r := bytes.NewReader(es.msgBuf.Bytes())
enc, err := gpgbin.Encrypt(r, es.to, es.from)
if err != nil {
return err
}
_, err = io.Copy(es.encryptedWriter, rfc822.NewCRLFReader(bytes.NewReader(enc)))
if err != nil {
return fmt.Errorf("gpg: failed to write encrypted writer: %w", err)
}
return nil
}
type Signer struct {
mw *textproto.MultipartWriter
signedMsg bytes.Buffer
w io.Writer
from string
header textproto.Header
}
func (s *Signer) Write(p []byte) (int, error) {
return s.signedMsg.Write(p)
}
func (s *Signer) Close() (err error) {
reader := bufio.NewReader(&s.signedMsg)
header, err := textproto.ReadHeader(reader)
if err != nil {
return err
}
// Make sure that MIME-Version is *not* set on the signed part header.
// It must be set *only* on the top level header.
//
// Some MTAs actually normalize the case of all headers (including
// signed text parts). MIME-Version can be normalized to different
// casing depending on the implementation (MIME- vs Mime-).
//
// Since the signature is computed on the whole part, including its
// header, changing the case can cause the signature to become invalid.
header.Del("Mime-Version")
var buf bytes.Buffer
_ = textproto.WriteHeader(&buf, header)
_, _ = io.Copy(&buf, reader)
pinentry.Enable()
defer pinentry.Disable()
sig, micalg, err := gpgbin.Sign(bytes.NewReader(buf.Bytes()), s.from)
if err != nil {
return err
}
params := map[string]string{
"boundary": s.mw.Boundary(),
"protocol": "application/pgp-signature",
"micalg": micalg,
}
s.header.Set("Content-Type", mime.FormatMediaType("multipart/signed", params))
// Ensure Mime-Version header is set on the top level to be compliant
// with RFC 2045
s.header.Set("Mime-Version", "1.0")
if err = textproto.WriteHeader(s.w, s.header); err != nil {
return err
}
boundary := s.mw.Boundary()
fmt.Fprintf(s.w, "--%s\r\n", boundary)
_, _ = s.w.Write(buf.Bytes())
_, _ = s.w.Write([]byte("\r\n"))
var signedHeader textproto.Header
signedHeader.Set("Content-Type", "application/pgp-signature; name=\"signature.asc\"")
signatureWriter, err := s.mw.CreatePart(signedHeader)
if err != nil {
return err
}
_, err = io.Copy(signatureWriter, rfc822.NewCRLFReader(bytes.NewReader(sig)))
if err != nil {
return err
}
return nil
}
// for tests
var forceBoundary = ""
type multiCloser []io.Closer
func (mc multiCloser) Close() error {
for _, c := range mc {
if err := c.Close(); err != nil {
return err
}
}
return nil
}
func Encrypt(w io.Writer, h textproto.Header, rcpts []string, from string) (io.WriteCloser, error) {
mw := textproto.NewMultipartWriter(w)
if forceBoundary != "" {
err := mw.SetBoundary(forceBoundary)
if err != nil {
return nil, fmt.Errorf("gpg: failed to set boundary: %w", err)
}
}
params := map[string]string{
"boundary": mw.Boundary(),
"protocol": "application/pgp-encrypted",
}
h.Set("Content-Type", mime.FormatMediaType("multipart/encrypted", params))
// Ensure Mime-Version header is set on the top level to be compliant
// with RFC 2045
h.Set("Mime-Version", "1.0")
if err := textproto.WriteHeader(w, h); err != nil {
return nil, err
}
var controlHeader textproto.Header
controlHeader.Set("Content-Type", "application/pgp-encrypted")
controlWriter, err := mw.CreatePart(controlHeader)
if err != nil {
return nil, err
}
if _, err = controlWriter.Write([]byte("Version: 1\r\n")); err != nil {
return nil, err
}
var encryptedHeader textproto.Header
encryptedHeader.Set("Content-Type", "application/octet-stream")
encryptedWriter, err := mw.CreatePart(encryptedHeader)
if err != nil {
return nil, err
}
var buf bytes.Buffer
plaintext := &EncrypterSigner{
msgBuf: buf,
encryptedWriter: encryptedWriter,
to: rcpts,
from: from,
}
return struct {
io.Writer
io.Closer
}{
plaintext,
multiCloser{
plaintext,
mw,
},
}, nil
}
func Sign(w io.Writer, h textproto.Header, from string) (io.WriteCloser, error) {
mw := textproto.NewMultipartWriter(w)
if forceBoundary != "" {
err := mw.SetBoundary(forceBoundary)
if err != nil {
return nil, fmt.Errorf("gpg: failed to set boundary: %w", err)
}
}
var msg bytes.Buffer
plaintext := &Signer{
mw: mw,
signedMsg: msg,
w: w,
from: from,
header: h,
}
return struct {
io.Writer
io.Closer
}{
plaintext,
multiCloser{
plaintext,
mw,
},
}, nil
}
+149
View File
@@ -0,0 +1,149 @@
package gpg
import (
"bytes"
"io"
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/textproto"
)
func init() {
forceBoundary = "foo"
}
type writerTestCase struct {
name string
method string
body string
to []string
expectedErr string
}
func TestWriter(t *testing.T) {
initGPGtest(t)
importSecretKey()
importPublicKey()
importOwnertrust()
testCases := []writerTestCase{
{
name: "Encrypt",
method: "encrypt",
body: "This is an encrypted message!\r\n",
to: []string{"john.doe@example.org"},
},
{
name: "Sign",
method: "sign",
body: "This is a signed message!\r\n",
to: []string{"john.doe@example.org"},
},
{
name: "Encrypt to untrusted",
method: "encrypt",
body: "This is an encrypted message!\r\n",
to: []string{"jane.doe@example.org"},
expectedErr: "gpg: failure to encrypt: gpg: public key of jane.doe@example.org is not trusted. check public key(s)",
},
}
var h textproto.Header
h.Set("From", "John Doe <john.doe@example.org>")
h.Set("To", "John Doe <john.doe@example.org>")
var header textproto.Header
header.Set("Content-Type", "text/plain")
from := "john.doe@example.org"
var err error
for _, tc := range testCases {
t.Logf("Test case: %s", tc.name)
var (
buf bytes.Buffer
cleartext io.WriteCloser
)
switch tc.method {
case "encrypt":
cleartext, err = Encrypt(&buf, h, tc.to, from)
if err != nil {
t.Fatalf("Encrypt() = %v", err)
}
case "sign":
cleartext, err = Sign(&buf, h, from)
if err != nil {
t.Fatalf("Encrypt() = %v", err)
}
}
if err = textproto.WriteHeader(cleartext, header); err != nil {
t.Fatalf("textproto.WriteHeader() = %v", err)
}
if _, err = io.WriteString(cleartext, tc.body); err != nil {
t.Fatalf("io.WriteString() = %v", err)
}
if err = cleartext.Close(); err != nil {
if err.Error() == tc.expectedErr {
continue
}
t.Fatalf("ciphertext.Close() = %v", err)
}
if tc.expectedErr != "" {
t.Fatalf("Expected error %v, but got %v", tc.expectedErr, err)
}
switch tc.method {
case "encrypt":
validateEncrypt(t, buf)
case "sign":
validateSign(t, buf)
}
}
}
func validateEncrypt(t *testing.T, buf bytes.Buffer) {
md, err := gpgbin.Decrypt(&buf)
if err != nil {
t.Errorf("Encrypt error: could not decrypt test encryption")
}
var body bytes.Buffer
io.Copy(&body, md.Body)
if s := body.String(); s != wantEncrypted {
t.Errorf("Encrypt() = \n%v\n but want \n%v", s, wantEncrypted)
}
}
func validateSign(t *testing.T, buf bytes.Buffer) {
parts := strings.Split(buf.String(), "\r\n--foo\r\n")
msg := strings.NewReader(parts[1])
sig := strings.NewReader(parts[2])
md, err := gpgbin.Verify(msg, sig)
if err != nil {
t.Fatalf("gpg.Verify() = %v", err)
}
deepEqual(t, "Sign", md, &wantSigned)
}
var wantEncrypted = toCRLF(`Content-Type: text/plain
This is an encrypted message!
`)
var wantSignedBody = toCRLF(`Content-Type: text/plain
This is a signed message!
`)
var wantSigned = models.MessageDetails{
IsEncrypted: false,
IsSigned: true,
SignedBy: "John Doe (This is a test key) <john.doe@example.org>",
SignedByKeyId: 3490876580878068068,
SignatureError: "",
DecryptedWith: "",
DecryptedWithKeyId: 0,
Body: strings.NewReader(wantSignedBody),
Micalg: "pgp-sha256",
}
+328
View File
@@ -0,0 +1,328 @@
package pgp
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rjarry/aerc/models"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-pgpmail"
"github.com/pkg/errors"
)
type Mail struct{}
var (
Keyring openpgp.EntityList
locked bool
)
func (m *Mail) KeyringExists() bool {
keypath := xdg.DataPath("aerc", "keyring.asc")
keyfile, err := os.Open(keypath)
if err != nil {
return false
}
defer keyfile.Close()
_, err = openpgp.ReadKeyRing(keyfile)
return err == nil
}
func (m *Mail) Init() error {
log.Debugf("Initializing PGP keyring")
err := os.MkdirAll(xdg.DataPath("aerc"), 0o700)
if err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
lockpath := xdg.DataPath("aerc", "keyring.lock")
lockfile, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
// TODO: Consider connecting to main process over IPC socket
locked = false
} else {
locked = true
lockfile.Close()
}
keypath := xdg.DataPath("aerc", "keyring.asc")
keyfile, err := os.Open(keypath)
if os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
defer keyfile.Close()
Keyring, err = openpgp.ReadKeyRing(keyfile)
if err != nil {
return err
}
return nil
}
func (m *Mail) Close() {
if !locked {
return
}
lockpath := xdg.DataPath("aerc", "keyring.lock")
os.Remove(lockpath)
}
func (m *Mail) getEntityByEmail(email string) (e *openpgp.Entity, err error) {
for _, entity := range Keyring {
ident := entity.PrimaryIdentity()
if ident != nil && ident.UserId.Email == email {
return entity, nil
}
}
return nil, fmt.Errorf("entity not found in keyring")
}
func (m *Mail) getSignerEntityByKeyId(id string) (*openpgp.Entity, error) {
id = strings.ToUpper(id)
for _, key := range Keyring.DecryptionKeys() {
if key.Entity == nil {
continue
}
kId := key.Entity.PrimaryKey.KeyIdString()
if strings.Contains(kId, id) {
return key.Entity, nil
}
}
return nil, fmt.Errorf("entity not found in keyring")
}
func (m *Mail) getSignerEntityByEmail(email string) (e *openpgp.Entity, err error) {
for _, key := range Keyring.DecryptionKeys() {
if key.Entity == nil {
continue
}
ident := key.Entity.PrimaryIdentity()
if ident != nil && ident.UserId.Email == email {
return key.Entity, nil
}
}
return nil, fmt.Errorf("entity not found in keyring")
}
func (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) {
md := new(models.MessageDetails)
pgpReader, err := pgpmail.Read(r, Keyring, decryptKeys, nil)
if err != nil {
return nil, err
}
if pgpReader.MessageDetails.IsEncrypted {
md.IsEncrypted = true
md.DecryptedWith = pgpReader.MessageDetails.DecryptedWith.Entity.PrimaryIdentity().Name
md.DecryptedWithKeyId = pgpReader.MessageDetails.DecryptedWith.PublicKey.KeyId
}
if pgpReader.MessageDetails.IsSigned {
// we should consume the UnverifiedBody until EOF in order
// to get the correct signature data
data, err := io.ReadAll(pgpReader.MessageDetails.UnverifiedBody)
if err != nil {
return nil, err
}
pgpReader.MessageDetails.UnverifiedBody = bytes.NewReader(data)
md.IsSigned = true
md.SignedBy = ""
md.SignedByKeyId = pgpReader.MessageDetails.SignedByKeyId
md.SignatureValidity = models.Valid
if pgpReader.MessageDetails.SignatureError != nil {
md.SignatureError = pgpReader.MessageDetails.SignatureError.Error()
md.SignatureValidity = handleSignatureError(md.SignatureError)
}
if pgpReader.MessageDetails.SignedBy != nil {
md.SignedBy = pgpReader.MessageDetails.SignedBy.Entity.PrimaryIdentity().Name
}
}
md.Body = pgpReader.MessageDetails.UnverifiedBody
return md, nil
}
func (m *Mail) ImportKeys(r io.Reader) error {
keys, err := openpgp.ReadKeyRing(r)
if err != nil {
return err
}
Keyring = append(Keyring, keys...)
if locked {
keypath := xdg.DataPath("aerc", "keyring.asc")
keyfile, err := os.OpenFile(keypath, os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer keyfile.Close()
for _, key := range keys {
if key.PrivateKey != nil {
err = key.SerializePrivate(keyfile, &packet.Config{})
} else {
err = key.Serialize(keyfile)
}
if err != nil {
return err
}
}
}
return nil
}
func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
var err error
var to []*openpgp.Entity
var signerEntity *openpgp.Entity
if signer != "" {
signerEntity, err = m.getSigner(signer, decryptKeys)
if err != nil {
return nil, err
}
}
for _, rcpt := range rcpts {
toEntity, err := m.getEntityByEmail(rcpt)
if err != nil {
return nil, errors.Wrap(err, "no key for "+rcpt)
}
to = append(to, toEntity)
}
cleartext, err := pgpmail.Encrypt(buf, header.Header.Header,
to, signerEntity, nil)
if err != nil {
return nil, err
}
return cleartext, nil
}
func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) {
var err error
var signerEntity *openpgp.Entity
if signer != "" {
signerEntity, err = m.getSigner(signer, decryptKeys)
if err != nil {
return nil, err
}
}
cleartext, err := pgpmail.Sign(buf, header.Header.Header, signerEntity, nil)
if err != nil {
return nil, err
}
return cleartext, nil
}
func (m *Mail) getSigner(signer string, decryptKeys openpgp.PromptFunction) (signerEntity *openpgp.Entity, err error) {
switch strings.Contains(signer, "@") {
case true:
signerEntity, err = m.getSignerEntityByEmail(signer)
if err != nil {
return nil, err
}
case false:
signerEntity, err = m.getSignerEntityByKeyId(signer)
if err != nil {
return nil, err
}
}
key, ok := signerEntity.SigningKey(time.Now())
if !ok {
return nil, fmt.Errorf("no signing key found for %s", signer)
}
if !key.PrivateKey.Encrypted {
return signerEntity, nil
}
_, err = decryptKeys([]openpgp.Key{key}, false)
if err != nil {
return nil, err
}
return signerEntity, nil
}
func (m *Mail) GetSignerKeyId(s string) (string, error) {
var err error
var signerEntity *openpgp.Entity
switch strings.Contains(s, "@") {
case true:
signerEntity, err = m.getSignerEntityByEmail(s)
if err != nil {
return "", err
}
case false:
signerEntity, err = m.getSignerEntityByKeyId(s)
if err != nil {
return "", err
}
}
return signerEntity.PrimaryKey.KeyIdString(), nil
}
func (m *Mail) GetKeyId(s string) (string, error) {
entity, err := m.getEntityByEmail(s)
if err != nil {
return "", err
}
return entity.PrimaryKey.KeyIdString(), nil
}
func (m *Mail) ExportKey(k string) (io.Reader, error) {
var err error
var entity *openpgp.Entity
switch strings.Contains(k, "@") {
case true:
entity, err = m.getSignerEntityByEmail(k)
if err != nil {
return nil, err
}
case false:
entity, err = m.getSignerEntityByKeyId(k)
if err != nil {
return nil, err
}
}
pks := bytes.NewBuffer(nil)
err = entity.Serialize(pks)
if err != nil {
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
}
pka := bytes.NewBuffer(nil)
w, err := armor.Encode(pka, "PGP PUBLIC KEY BLOCK", map[string]string{})
if err != nil {
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
}
_, err = w.Write(pks.Bytes())
if err != nil {
return nil, fmt.Errorf("pgp: error exporting key: %w", err)
}
w.Close()
return pka, nil
}
func handleSignatureError(e string) models.SignatureValidity {
if e == "openpgp: signature made by unknown entity" {
return models.UnknownEntity
}
if strings.HasPrefix(e, "pgpmail: unsupported micalg") {
return models.UnsupportedMicalg
}
if strings.HasPrefix(e, "pgpmail") {
return models.InvalidSignature
}
return models.UnknownValidity
}
+69
View File
@@ -0,0 +1,69 @@
package cryptoutil
import (
"bytes"
"errors"
"io"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
"github.com/emersion/go-message/mail"
)
func Cleartext(r io.Reader, header mail.Header) ([]byte, error) {
msg, err := app.CryptoProvider().Decrypt(
rfc822.NewCRLFReader(r), app.DecryptKeys)
if err != nil {
return nil, errors.New("decrypt error")
}
full, err := createMessage(header, msg.Body)
if err != nil {
return nil, errors.New("failed to create decrypted message")
}
return full, nil
}
func createMessage(header mail.Header, body io.Reader) ([]byte, error) {
e, err := rfc822.ReadMessage(body)
if err != nil {
return nil, err
}
// copy the header values from the "decrypted body". This should set
// the correct content type.
hf := e.Header.Fields()
for hf.Next() {
header.Set(hf.Key(), hf.Value())
}
ctype, params, err := header.ContentType()
if err != nil {
return nil, err
}
// in case there remains a multipart/{encrypted,signed} content type,
// manually correct them to multipart/mixed as a fallback.
ct := strings.ToLower(ctype)
if strings.Contains(ct, "multipart/encrypted") ||
strings.Contains(ct, "multipart/signed") {
delete(params, "protocol")
delete(params, "micalg")
header.SetContentType("multipart/mixed", params)
}
// a SingleInlineWriter is sufficient since the "decrypted body"
// already contains the proper boundaries of the parts; we just want to
// combine it with the headers.
var message bytes.Buffer
w, err := mail.CreateSingleInlineWriter(&message, header)
if err != nil {
return nil, err
}
if _, err := io.Copy(w, e.Body); err != nil {
return nil, err
}
w.Close()
return message.Bytes(), nil
}
+51
View File
@@ -0,0 +1,51 @@
package lib
import (
"git.sr.ht/~rjarry/aerc/lib/sort"
"git.sr.ht/~rjarry/aerc/models"
)
type DirStore struct {
dirs map[string]*models.Directory
msgStores map[string]*MessageStore
order []string
}
func NewDirStore() *DirStore {
return &DirStore{
dirs: make(map[string]*models.Directory),
msgStores: make(map[string]*MessageStore),
}
}
func (store *DirStore) List() []string {
dirs := []string{}
for dir := range store.msgStores {
dirs = append(dirs, dir)
}
sort.SortStringBy(dirs, store.order)
return dirs
}
func (store *DirStore) MessageStore(dirname string) (*MessageStore, bool) {
msgStore, ok := store.msgStores[dirname]
return msgStore, ok
}
func (store *DirStore) SetMessageStore(dir *models.Directory, msgStore *MessageStore) {
s := dir.Name
if _, ok := store.dirs[s]; !ok {
store.order = append(store.order, s)
}
store.dirs[dir.Name] = dir
store.msgStores[dir.Name] = msgStore
}
func (store *DirStore) Remove(name string) {
delete(store.dirs, name)
delete(store.msgStores, name)
}
func (store *DirStore) Directory(name string) *models.Directory {
return store.dirs[name]
}
+23
View File
@@ -0,0 +1,23 @@
package lib_test
import (
"reflect"
"testing"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/models"
)
func TestDirStore_List(t *testing.T) {
dirs := []string{"a/c", "x", "a/b", "d"}
dirstore := lib.NewDirStore()
for _, d := range dirs {
dirstore.SetMessageStore(&models.Directory{Name: d}, nil)
}
for i := 0; i < 10; i++ {
if !reflect.DeepEqual(dirstore.List(), dirs) {
t.Errorf("order does not match")
return
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package lib
import (
"bytes"
"io"
"github.com/ProtonMail/go-crypto/openpgp"
_ "github.com/emersion/go-message/charset"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
"git.sr.ht/~rjarry/aerc/models"
)
// EmlMessage implements the RawMessage interface
type EmlMessage []byte
func (fm *EmlMessage) NewReader() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(*fm)), nil
}
func (fm *EmlMessage) UID() models.UID {
return ""
}
func (fm *EmlMessage) Labels() ([]string, error) {
return nil, nil
}
func (fm *EmlMessage) ModelFlags() (models.Flags, error) {
return models.SeenFlag, nil
}
// NewEmlMessageView provides a MessageView for a full message that is not
// stored in a message store
func NewEmlMessageView(full []byte, pgp crypto.Provider,
decryptKeys openpgp.PromptFunction, cb func(MessageView, error),
) {
eml := EmlMessage(full)
messageInfo, err := rfc822.MessageInfo(&eml)
if err != nil {
cb(nil, err)
return
}
msv := &MessageStoreView{
messageInfo: messageInfo,
messageStore: nil,
message: full,
details: nil,
bodyStructure: nil,
setSeen: false,
}
if usePGP(messageInfo.BodyStructure) {
reader := rfc822.NewCRLFReader(bytes.NewReader(full))
md, err := pgp.Decrypt(reader, decryptKeys)
if err != nil {
cb(nil, err)
return
}
msv.details = md
msv.message, err = io.ReadAll(md.Body)
if err != nil {
cb(nil, err)
return
}
}
entity, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message))
if err != nil {
cb(nil, err)
return
}
bs, err := rfc822.ParseEntityStructure(entity)
if rfc822.IsMultipartError(err) {
log.Warnf("EmlView: %v", err)
bs = rfc822.CreateTextPlainBody()
} else if err != nil {
cb(nil, err)
return
}
msv.bodyStructure = bs
cb(msv, nil)
}
+88
View File
@@ -0,0 +1,88 @@
package format
import (
"fmt"
"strings"
"time"
"unicode"
"github.com/emersion/go-message/mail"
)
const rfc5322specials string = `()<>[]:;@\,."`
// AddressForHumans formats the address.
// Meant for display purposes to the humans, not for sending over the wire.
func AddressForHumans(a *mail.Address) string {
if a.Name != "" {
if strings.ContainsAny(a.Name, rfc5322specials) {
return fmt.Sprintf("\"%s\" <%s>",
strings.ReplaceAll(a.Name, "\"", "'"), a.Address)
} else {
return fmt.Sprintf("%s <%s>", a.Name, a.Address)
}
} else {
return fmt.Sprintf("<%s>", a.Address)
}
}
// FormatAddresses formats a list of addresses into a human readable string
func FormatAddresses(l []*mail.Address) string {
formatted := make([]string, len(l))
for i, a := range l {
formatted[i] = AddressForHumans(a)
}
return strings.Join(formatted, ", ")
}
// CompactPath reduces a directory path into a compact form. The directory
// name will be split with the provided separator and each part will be reduced
// to the first letter in its name: INBOX/01_WORK/PROJECT will become
// I/W/PROJECT.
func CompactPath(name string, sep rune) (compact string) {
parts := strings.Split(name, string(sep))
for i, part := range parts {
if i == len(parts)-1 {
compact += part
} else {
if len(part) != 0 {
r := part[0]
for i := 0; i < len(part)-1; i++ {
if unicode.IsLetter(rune(part[i])) {
r = part[i]
break
}
}
compact += fmt.Sprintf("%c%c", r, sep)
} else {
compact += fmt.Sprintf("%c", sep)
}
}
}
return
}
func DummyIfZeroDate(date time.Time, format string, todayFormat string,
thisWeekFormat string, thisYearFormat string,
) string {
if date.IsZero() {
return strings.Repeat("?", len(format))
}
year := date.Year()
day := date.YearDay()
now := time.Now()
thisYear := now.Year()
thisDay := now.YearDay()
if year == thisYear {
if day == thisDay && todayFormat != "" {
return date.Format(todayFormat)
}
if day > thisDay-7 && thisWeekFormat != "" {
return date.Format(thisWeekFormat)
}
if thisYearFormat != "" {
return date.Format(thisYearFormat)
}
}
return date.Format(format)
}
+13
View File
@@ -0,0 +1,13 @@
package lib
// History represents a list of elements ordered by time.
type History interface {
// Add a new element to the history
Add(string)
// Get the next element in history
Next() string
// Get the previous element in history
Prev() string
// Reset the current location in history
Reset()
}
+22
View File
@@ -0,0 +1,22 @@
package hooks
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/config"
)
type AercShutdown struct {
Lifetime time.Duration
}
func (a *AercShutdown) Cmd() string {
return config.Hooks.AercShutdown
}
func (a *AercShutdown) Env() []string {
return []string{
fmt.Sprintf("AERC_LIFETIME=%s", a.Lifetime.String()),
}
}
+23
View File
@@ -0,0 +1,23 @@
package hooks
import (
"fmt"
"os"
"git.sr.ht/~rjarry/aerc/config"
)
type AercStartup struct {
Version string
}
func (m *AercStartup) Cmd() string {
return config.Hooks.AercStartup
}
func (m *AercStartup) Env() []string {
return []string{
fmt.Sprintf("AERC_VERSION=%s", m.Version),
fmt.Sprintf("AERC_BINARY=%s", os.Args[0]),
}
}
+31
View File
@@ -0,0 +1,31 @@
package hooks
import (
"bytes"
"os"
"os/exec"
"git.sr.ht/~rjarry/aerc/lib/log"
)
func RunHook(h HookType) error {
cmd := h.Cmd()
if cmd == "" {
return nil
}
env := h.Env()
log.Debugf("hooks: running command %q (env %v)", cmd, env)
proc := exec.Command("sh", "-c", cmd)
var outb, errb bytes.Buffer
proc.Stdout = &outb
proc.Stderr = &errb
proc.Env = os.Environ()
proc.Env = append(proc.Env, env...)
err := proc.Run()
log.Tracef("hooks: %q stdout: %s", cmd, outb.String())
if err != nil {
log.Errorf("hooks:%q stderr: %s", cmd, errb.String())
}
return err
}
+31
View File
@@ -0,0 +1,31 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
)
type FlagChanged struct {
Account string
Backend string
Folder string
Role string
FlagName string
}
func (m *FlagChanged) Cmd() string {
return config.Hooks.FlagChanged
}
func (m *FlagChanged) Env() []string {
env := []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend),
fmt.Sprintf("AERC_FOLDER=%s", m.Folder),
fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role),
fmt.Sprintf("AERC_FLAG=%s", m.FlagName),
}
return env
}
+6
View File
@@ -0,0 +1,6 @@
package hooks
type HookType interface {
Cmd() string
Env() []string
}
+27
View File
@@ -0,0 +1,27 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
)
type MailAdded struct {
Account string
Backend string
Folder string
Role string
}
func (m *MailAdded) Cmd() string {
return config.Hooks.MailAdded
}
func (m *MailAdded) Env() []string {
return []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend),
fmt.Sprintf("AERC_FOLDER=%s", m.Folder),
fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role),
}
}
+27
View File
@@ -0,0 +1,27 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
)
type MailDeleted struct {
Account string
Backend string
Folder string
Role string
}
func (m *MailDeleted) Cmd() string {
return config.Hooks.MailDeleted
}
func (m *MailDeleted) Env() []string {
return []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend),
fmt.Sprintf("AERC_FOLDER=%s", m.Folder),
fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role),
}
}
+34
View File
@@ -0,0 +1,34 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/models"
)
type MailReceived struct {
Account string
Backend string
Folder string
Role string
MsgInfo *models.MessageInfo
}
func (m *MailReceived) Cmd() string {
return config.Hooks.MailReceived
}
func (m *MailReceived) Env() []string {
from := m.MsgInfo.Envelope.From[0]
return []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend),
fmt.Sprintf("AERC_FOLDER=%s", m.Folder),
fmt.Sprintf("AERC_FROM_NAME=%s", from.Name),
fmt.Sprintf("AERC_FROM_ADDRESS=%s", from.Address),
fmt.Sprintf("AERC_SUBJECT=%s", m.MsgInfo.Envelope.Subject),
fmt.Sprintf("AERC_MESSAGE_ID=%s", m.MsgInfo.Envelope.MessageId),
fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role),
}
}
+33
View File
@@ -0,0 +1,33 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
"github.com/emersion/go-message/mail"
)
type MailSent struct {
Account string
Backend string
Header *mail.Header
}
func (m *MailSent) Cmd() string {
return config.Hooks.MailSent
}
func (m *MailSent) Env() []string {
from, _ := mail.ParseAddress(m.Header.Get("From"))
env := []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend),
fmt.Sprintf("AERC_FROM_NAME=%s", from.Name),
fmt.Sprintf("AERC_FROM_ADDRESS=%s", from.Address),
fmt.Sprintf("AERC_SUBJECT=%s", m.Header.Get("Subject")),
fmt.Sprintf("AERC_TO=%s", m.Header.Get("To")),
fmt.Sprintf("AERC_CC=%s", m.Header.Get("Cc")),
}
return env
}
+28
View File
@@ -0,0 +1,28 @@
package hooks
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
)
type TagModified struct {
Account string
Backend string
Add []string
Remove []string
}
func (m *TagModified) Cmd() string {
return config.Hooks.TagModified
}
func (m *TagModified) Env() []string {
env := []string{
fmt.Sprintf("AERC_ACCOUNT=%s", m.Account),
fmt.Sprintf("AERC_TAG_ADDED=%v", m.Add),
fmt.Sprintf("AERC_TAG_REMOVED=%v", m.Remove),
}
return env
}
+5
View File
@@ -0,0 +1,5 @@
package ipc
type Handler interface {
Command(args []string) error
}
+52
View File
@@ -0,0 +1,52 @@
package ipc
import "encoding/json"
// Request contains all parameters needed for the main instance to respond to
// a request.
type Request struct {
// Arguments contains the commandline arguments. The detection of what
// action to take is left to the receiver.
Arguments []string `json:"arguments"`
}
// Response is used to report the results of a command.
type Response struct {
// Error contains the success-state of the command. Error is an empty
// string if everything ran successfully.
Error string `json:"error"`
}
// Encode transforms the message in an easier to transfer format
func (msg *Request) Encode() ([]byte, error) {
return json.Marshal(msg)
}
// DecodeMessage consumes a raw message and returns the message contained
// within.
func DecodeMessage(data []byte) (*Request, error) {
msg := new(Request)
err := json.Unmarshal(data, msg)
return msg, err
}
// Encode transforms the message in an easier to transfer format
func (msg *Response) Encode() ([]byte, error) {
return json.Marshal(msg)
}
// DecodeRequest consumes a raw message and returns the message contained
// within.
func DecodeRequest(data []byte) (*Request, error) {
msg := new(Request)
err := json.Unmarshal(data, msg)
return msg, err
}
// DecodeResponse consumes a raw message and returns the message contained
// within.
func DecodeResponse(data []byte) (*Response, error) {
msg := new(Response)
err := json.Unmarshal(data, msg)
return msg, err
}
+104
View File
@@ -0,0 +1,104 @@
package ipc
import (
"bufio"
"context"
"errors"
"net"
"os"
"sync/atomic"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
type AercServer struct {
listener net.Listener
handler Handler
startup context.Context
}
func StartServer(handler Handler, startup context.Context) (*AercServer, error) {
sockpath := xdg.RuntimePath("aerc.sock")
// remove the socket if it is not connected to a session
if _, err := ConnectAndExec(nil); err != nil {
os.Remove(sockpath)
}
log.Debugf("Starting Unix server: %s", sockpath)
l, err := net.Listen("unix", sockpath)
if err != nil {
return nil, err
}
as := &AercServer{listener: l, handler: handler, startup: startup}
go as.Serve()
return as, nil
}
func (as *AercServer) Close() {
as.listener.Close()
}
var lastId int64 = 0 // access via atomic
func (as *AercServer) Serve() {
defer log.PanicHandler()
<-as.startup.Done()
for {
conn, err := as.listener.Accept()
switch {
case errors.Is(err, net.ErrClosed):
log.Infof("shutting down UNIX listener")
return
case err != nil:
log.Errorf("ipc: accepting connection failed: %v", err)
continue
}
defer conn.Close()
clientId := atomic.AddInt64(&lastId, 1)
log.Debugf("unix:%d accepted connection", clientId)
scanner := bufio.NewScanner(conn)
err = conn.SetDeadline(time.Now().Add(1 * time.Minute))
if err != nil {
log.Errorf("unix:%d failed to set deadline: %v", clientId, err)
}
for scanner.Scan() {
// allow up to 1 minute between commands
err = conn.SetDeadline(time.Now().Add(1 * time.Minute))
if err != nil {
log.Errorf("unix:%d failed to update deadline: %v", clientId, err)
}
msg, err := DecodeRequest(scanner.Bytes())
log.Tracef("unix:%d got message %s", clientId, scanner.Text())
if err != nil {
log.Errorf("unix:%d failed to parse request: %v", clientId, err)
continue
}
response := as.handleMessage(msg)
result, err := response.Encode()
if err != nil {
log.Errorf("unix:%d failed to encode result: %v", clientId, err)
continue
}
_, err = conn.Write(append(result, '\n'))
if err != nil {
log.Errorf("unix:%d failed to send response: %v", clientId, err)
break
}
}
log.Tracef("unix:%d closed connection", clientId)
}
}
func (as *AercServer) handleMessage(req *Request) *Response {
err := as.handler.Command(req.Arguments)
if err != nil {
return &Response{Error: err.Error()}
}
return &Response{}
}
+39
View File
@@ -0,0 +1,39 @@
package ipc
import (
"bufio"
"errors"
"fmt"
"net"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
func ConnectAndExec(args []string) (*Response, error) {
sockpath := xdg.RuntimePath("aerc.sock")
conn, err := net.Dial("unix", sockpath)
if err != nil {
return nil, err
}
defer conn.Close()
req, err := (&Request{Arguments: args}).Encode()
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
_, err = conn.Write(append(req, '\n'))
if err != nil {
return nil, fmt.Errorf("failed to send message: %w", err)
}
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
return nil, errors.New("No response from server")
}
resp, err := DecodeResponse(scanner.Bytes())
if err != nil {
return nil, err
}
return resp, nil
}
+126
View File
@@ -0,0 +1,126 @@
package iterator
import (
"errors"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
// defaultFactory
type defaultFactory struct{}
func (df *defaultFactory) NewIterator(a interface{}) Iterator {
switch data := a.(type) {
case []models.UID:
return &defaultUid{data: data, index: len(data)}
case []*types.Thread:
return &defaultThread{data: data, index: len(data)}
}
panic(errors.New("a iterator for this type is not implemented yet"))
}
// defaultUid
type defaultUid struct {
data []models.UID
index int
}
func (du *defaultUid) Next() bool {
du.index--
return du.index >= 0
}
func (du *defaultUid) Value() interface{} {
return du.data[du.index]
}
func (du *defaultUid) StartIndex() int {
return len(du.data) - 1
}
func (du *defaultUid) EndIndex() int {
return 0
}
// defaultThread
type defaultThread struct {
data []*types.Thread
index int
}
func (dt *defaultThread) Next() bool {
dt.index--
return dt.index >= 0
}
func (dt *defaultThread) Value() interface{} {
return dt.data[dt.index]
}
func (dt *defaultThread) StartIndex() int {
return len(dt.data) - 1
}
func (dt *defaultThread) EndIndex() int {
return 0
}
// reverseFactory
type reverseFactory struct{}
func (rf *reverseFactory) NewIterator(a interface{}) Iterator {
switch data := a.(type) {
case []models.UID:
return &reverseUid{data: data, index: -1}
case []*types.Thread:
return &reverseThread{data: data, index: -1}
}
panic(errors.New("an iterator for this type is not implemented yet"))
}
// reverseUid
type reverseUid struct {
data []models.UID
index int
}
func (ru *reverseUid) Next() bool {
ru.index++
return ru.index < len(ru.data)
}
func (ru *reverseUid) Value() interface{} {
return ru.data[ru.index]
}
func (ru *reverseUid) StartIndex() int {
return 0
}
func (ru *reverseUid) EndIndex() int {
return len(ru.data) - 1
}
// reverseThread
type reverseThread struct {
data []*types.Thread
index int
}
func (rt *reverseThread) Next() bool {
rt.index++
return rt.index < len(rt.data)
}
func (rt *reverseThread) Value() interface{} {
return rt.data[rt.index]
}
func (rt *reverseThread) StartIndex() int {
return 0
}
func (rt *reverseThread) EndIndex() int {
return len(rt.data) - 1
}
+54
View File
@@ -0,0 +1,54 @@
package iterator
// IndexProvider implements a subset of the Iterator interface
type IndexProvider interface {
StartIndex() int
EndIndex() int
}
// FixBounds will force the index i to either its lower- or upper-bound value
// if out-of-bound
func FixBounds(i, lower, upper int) int {
switch {
case i > upper:
i = upper
case i < lower:
i = lower
}
return i
}
// WrapBounds will wrap the index i around its upper- or lower-bound if
// out-of-bound
func WrapBounds(i, lower, upper int) int {
if upper <= 0 {
return lower
}
switch {
case i > upper:
i = lower + (i-upper-1)%upper
case i < lower:
i = upper - (lower-i-1)%upper
}
return i
}
type BoundsCheckFunc func(int, int, int) int
// MoveIndex moves the index variable idx forward by delta steps and ensures
// that the boundary policy as defined by the CheckBoundsFunc is enforced.
//
// If CheckBoundsFunc is nil, fix boundary checks are performed.
func MoveIndex(idx, delta int, indexer IndexProvider, cb BoundsCheckFunc) int {
lower, upper := indexer.StartIndex(), indexer.EndIndex()
sign := 1
if upper < lower {
lower, upper = upper, lower
sign = -1
}
result := idx + sign*delta
if cb == nil {
return FixBounds(result, lower, upper)
}
return cb(result, lower, upper)
}
+133
View File
@@ -0,0 +1,133 @@
package iterator_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/iterator"
)
type indexer struct {
start int
end int
}
func (ip *indexer) StartIndex() int {
return ip.start
}
func (ip *indexer) EndIndex() int {
return ip.end
}
func TestMoveIndex(t *testing.T) {
tests := []struct {
idx int
delta int
start int
end int
cb iterator.BoundsCheckFunc
expected int
}{
{
idx: 0,
delta: 1,
start: 0,
end: 2,
cb: iterator.FixBounds,
expected: 1,
},
{
idx: 0,
delta: 5,
start: 0,
end: 2,
cb: iterator.FixBounds,
expected: 2,
},
{
idx: 0,
delta: -1,
start: 0,
end: 2,
cb: iterator.FixBounds,
expected: 0,
},
{
idx: 0,
delta: 2,
start: 0,
end: 2,
cb: iterator.WrapBounds,
expected: 2,
},
{
idx: 0,
delta: 3,
start: 0,
end: 2,
cb: iterator.WrapBounds,
expected: 0,
},
{
idx: 0,
delta: -1,
start: 0,
end: 2,
cb: iterator.WrapBounds,
expected: 2,
},
{
idx: 2,
delta: 2,
start: 0,
end: 2,
cb: iterator.WrapBounds,
expected: 1,
},
{
idx: 0,
delta: -2,
start: 0,
end: 2,
cb: iterator.WrapBounds,
expected: 1,
},
{
idx: 1,
delta: 1,
start: 2,
end: 0,
cb: iterator.FixBounds,
expected: 0,
},
{
idx: 0,
delta: 1,
start: 2,
end: 0,
cb: iterator.FixBounds,
expected: 0,
},
{
idx: 0,
delta: 1,
start: 2,
end: 0,
cb: iterator.WrapBounds,
expected: 2,
},
}
for i, test := range tests {
idx := iterator.MoveIndex(
test.idx,
test.delta,
&indexer{test.start, test.end},
test.cb,
)
if idx != test.expected {
t.Errorf("test %d [%#v] failed: got %d but expected %d",
i, test, idx, test.expected)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package iterator
// Factory is the interface that wraps the NewIterator method. The
// NewIterator() creates either UID or thread iterators and ensures that both
// types of iterators implement the same iteration direction.
type Factory interface {
NewIterator(a interface{}) Iterator
}
// Iterator implements an interface for iterating over UID or thread data. If
// Next() returns true, the current value of the iterator can be read with
// Value(). The return value of Value() is an interface{} type which needs to
// be cast to the correct type.
//
// The iterators are implemented such that the first returned value always
// represents the top message in the message list. Hence, StartIndex() would
// return the index of the top message whereas EndIndex() returns the index of
// message at the bottom of the list.
type Iterator interface {
Next() bool
Value() interface{}
StartIndex() int
EndIndex() int
}
// NewFactory creates an iterator factory. When reverse is true, the iterators
// are reversed in the sense that the lowest UID messages are displayed at the
// top of the message list. Otherwise, the default order is with the highest
// UID message on top.
func NewFactory(reverse bool) Factory {
if reverse {
return &reverseFactory{}
}
return &defaultFactory{}
}
+96
View File
@@ -0,0 +1,96 @@
package iterator_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/iterator"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
func toThreads(uids []models.UID) []*types.Thread {
threads := make([]*types.Thread, len(uids))
for i, u := range uids {
threads[i] = &types.Thread{Uid: u}
}
return threads
}
func TestIterator_DefaultFactory(t *testing.T) {
input := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"}
want := []models.UID{"9", "8", "7", "6", "5", "4", "3", "2", "1"}
factory := iterator.NewFactory(false)
if factory == nil {
t.Errorf("could not create factory")
}
start, end := len(input)-1, 0
checkUids(t, factory, input, want, start, end)
checkThreads(t, factory, toThreads(input),
toThreads(want), start, end)
}
func TestIterator_ReverseFactory(t *testing.T) {
input := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"}
want := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"}
factory := iterator.NewFactory(true)
if factory == nil {
t.Errorf("could not create factory")
}
start, end := 0, len(input)-1
checkUids(t, factory, input, want, start, end)
checkThreads(t, factory, toThreads(input),
toThreads(want), start, end)
}
func checkUids(t *testing.T, factory iterator.Factory,
input []models.UID, want []models.UID, start, end int,
) {
label := "uids"
got := make([]models.UID, 0)
iter := factory.NewIterator(input)
for iter.Next() {
got = append(got, iter.Value().(models.UID))
}
if len(got) != len(want) {
t.Errorf("%s: number of elements not correct", label)
}
for i, u := range want {
if got[i] != u {
t.Errorf("%s: order not correct", label)
}
}
if iter.StartIndex() != start {
t.Errorf("%s: start index not correct", label)
}
if iter.EndIndex() != end {
t.Errorf("%s: end index not correct", label)
}
}
func checkThreads(t *testing.T, factory iterator.Factory,
input []*types.Thread, want []*types.Thread, start, end int,
) {
label := "threads"
got := make([]*types.Thread, 0)
iter := factory.NewIterator(input)
for iter.Next() {
got = append(got, iter.Value().(*types.Thread))
}
if len(got) != len(want) {
t.Errorf("%s: number of elements not correct", label)
}
for i, th := range want {
if got[i].Uid != th.Uid {
t.Errorf("%s: order not correct", label)
}
}
if iter.StartIndex() != start {
t.Errorf("%s: start index not correct", label)
}
if iter.EndIndex() != end {
t.Errorf("%s: end index not correct", label)
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
// +build !linux
package lib
func SetTcpKeepaliveProbes(fd, count int) error {
return nil
}
func SetTcpKeepaliveInterval(fd, interval int) error {
return nil
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
// +build linux
package lib
import (
"syscall"
)
func SetTcpKeepaliveProbes(fd, count int) error {
return syscall.SetsockoptInt(
fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT, count)
}
func SetTcpKeepaliveInterval(fd, interval int) error {
return syscall.SetsockoptInt(
fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPINTVL, interval)
}
+188
View File
@@ -0,0 +1,188 @@
package log
import (
"fmt"
"io"
"log"
"os"
"strings"
)
type LogLevel int
const (
TRACE LogLevel = 5
DEBUG LogLevel = 10
INFO LogLevel = 20
WARN LogLevel = 30
ERROR LogLevel = 40
)
type logfilePtr struct {
f *os.File
useStdout bool
}
func newLogfilePtr(f *os.File, isStdout bool) *logfilePtr {
return &logfilePtr{f: f, useStdout: isStdout}
}
func (l *logfilePtr) Close() error {
if l.useStdout || l.f == nil {
return nil
}
return l.f.Close()
}
var (
trace *log.Logger
dbg *log.Logger
info *log.Logger
warn *log.Logger
err *log.Logger
minLevel LogLevel = TRACE
// logfile stores a pointer to the log file descriptor
logfile *logfilePtr
)
func Init(file *os.File, useStdout bool, level LogLevel) error {
trace = nil
dbg = nil
info = nil
warn = nil
err = nil
if logfile != nil {
e := logfile.Close()
if e != nil {
return e
}
logfile = nil
}
minLevel = level
flags := log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile
if file != nil {
logfile = newLogfilePtr(file, useStdout)
trace = log.New(file, "TRACE ", flags)
dbg = log.New(file, "DEBUG ", flags)
info = log.New(file, "INFO ", flags)
warn = log.New(file, "WARN ", flags)
err = log.New(file, "ERROR ", flags)
}
return nil
}
func ParseLevel(value string) (LogLevel, error) {
switch strings.ToLower(value) {
case "trace":
return TRACE, nil
case "debug":
return DEBUG, nil
case "info":
return INFO, nil
case "warn", "warning":
return WARN, nil
case "err", "error":
return ERROR, nil
}
return 0, fmt.Errorf("%s: invalid log level", value)
}
func ErrorLogger() *log.Logger {
if err == nil {
return log.New(io.Discard, "", log.LstdFlags)
}
return err
}
type Logger interface {
Tracef(string, ...interface{})
Debugf(string, ...interface{})
Infof(string, ...interface{})
Warnf(string, ...interface{})
Errorf(string, ...interface{})
}
type logger struct {
name string
calldepth int
}
func NewLogger(name string, calldepth int) Logger {
return &logger{name: name, calldepth: calldepth}
}
func (l *logger) format(message string, args ...interface{}) string {
if len(args) > 0 {
message = fmt.Sprintf(message, args...)
}
if l.name != "" {
message = fmt.Sprintf("[%s] %s", l.name, message)
}
return message
}
func (l *logger) Tracef(message string, args ...interface{}) {
if trace == nil || minLevel > TRACE {
return
}
message = l.format(message, args...)
trace.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log
}
func (l *logger) Debugf(message string, args ...interface{}) {
if dbg == nil || minLevel > DEBUG {
return
}
message = l.format(message, args...)
dbg.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log
}
func (l *logger) Infof(message string, args ...interface{}) {
if info == nil || minLevel > INFO {
return
}
message = l.format(message, args...)
info.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log
}
func (l *logger) Warnf(message string, args ...interface{}) {
if warn == nil || minLevel > WARN {
return
}
message = l.format(message, args...)
warn.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log
}
func (l *logger) Errorf(message string, args ...interface{}) {
if err == nil || minLevel > ERROR {
return
}
message = l.format(message, args...)
err.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log
}
var root = logger{calldepth: 3}
func Tracef(message string, args ...interface{}) {
root.Tracef(message, args...)
}
func Debugf(message string, args ...interface{}) {
root.Debugf(message, args...)
}
func Infof(message string, args ...interface{}) {
root.Infof(message, args...)
}
func Warnf(message string, args ...interface{}) {
root.Warnf(message, args...)
}
func Errorf(message string, args ...interface{}) {
root.Errorf(message, args...)
}
+60
View File
@@ -0,0 +1,60 @@
package log
import (
"fmt"
"io"
"os"
"runtime/debug"
"strings"
"time"
)
var (
UICleanup = func() {}
BuildInfo string
)
// PanicHandler tries to restore the terminal. A stack trace is written to
// aerc-crash.log and then passed on if a panic occurs.
func PanicHandler() {
r := recover()
if r == nil {
return
}
UICleanup()
filename := time.Now().Format("/tmp/aerc-crash-20060102-150405.log")
panicLog, err := os.OpenFile(filename, os.O_SYNC|os.O_APPEND|os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
if err != nil {
// we tried, not possible. bye
panic(r)
}
defer panicLog.Close()
outputs := io.MultiWriter(panicLog, os.Stderr)
// if any error happens here, we do not care.
fmt.Fprintln(panicLog, strings.Repeat("#", 80))
fmt.Fprint(panicLog, strings.Repeat(" ", 34))
fmt.Fprintln(panicLog, "PANIC CAUGHT!")
fmt.Fprint(panicLog, strings.Repeat(" ", 24))
fmt.Fprintln(panicLog, time.Now().Format("2006-01-02T15:04:05.000000-0700"))
fmt.Fprintln(panicLog, strings.Repeat("#", 80))
fmt.Fprintf(outputs, "%s\n", panicMessage)
fmt.Fprintf(outputs, "Version: %s\n", BuildInfo)
fmt.Fprintf(panicLog, "Error: %v\n\n", r)
panicLog.Write(debug.Stack()) //nolint:errcheck // we are already in a panic, so not much we can do here
fmt.Fprintf(os.Stderr, "\nThis error was also written to: %s\n", filename)
panic(r)
}
const panicMessage = `
aerc has encountered a critical error and has terminated. Please help us fix
this by sending this log and the steps to reproduce the crash to:
~rjarry/aerc-devel@lists.sr.ht
Thank you
`
+195
View File
@@ -0,0 +1,195 @@
package marker
import "git.sr.ht/~rjarry/aerc/models"
// Marker provides the interface for the marking behavior of messages
type Marker interface {
Mark(models.UID)
Unmark(models.UID)
ToggleMark(models.UID)
Remark()
Marked() []models.UID
IsMarked(models.UID) bool
IsVisualMark() bool
ToggleVisualMark(bool)
UpdateVisualMark()
ClearVisualMark()
}
// UIDProvider provides the underlying uids and the selected message index
type UIDProvider interface {
Uids() []models.UID
SelectedIndex() int
}
type controller struct {
uidProvider UIDProvider
marked map[models.UID]struct{}
lastMarked map[models.UID]struct{}
visualStartUID models.UID
visualMarkMode bool
visualBase map[models.UID]struct{}
}
// New returns a new Marker
func New(up UIDProvider) Marker {
return &controller{
uidProvider: up,
marked: make(map[models.UID]struct{}),
lastMarked: make(map[models.UID]struct{}),
}
}
// Mark marks the uid as marked
func (mc *controller) Mark(uid models.UID) {
if mc.visualMarkMode {
// visual mode has override, bogus input from user
return
}
mc.marked[uid] = struct{}{}
}
// Unmark unmarks the uid
func (mc *controller) Unmark(uid models.UID) {
if mc.visualMarkMode {
// user probably wanted to clear the visual marking
mc.ClearVisualMark()
return
}
delete(mc.marked, uid)
}
// Remark restores the previous marks
func (mc *controller) Remark() {
mc.marked = mc.lastMarked
}
// ToggleMark toggles the marked state for the given uid
func (mc *controller) ToggleMark(uid models.UID) {
if mc.visualMarkMode {
// visual mode has override, bogus input from user
return
}
if mc.IsMarked(uid) {
mc.Unmark(uid)
} else {
mc.Mark(uid)
}
}
// resetMark removes the marking from all messages
func (mc *controller) resetMark() {
mc.lastMarked = mc.marked
mc.marked = make(map[models.UID]struct{})
}
// removeStaleUID removes uids that are no longer presents in the UIDProvider
func (mc *controller) removeStaleUID() {
for mark := range mc.marked {
present := false
for _, uid := range mc.uidProvider.Uids() {
if mark == uid {
present = true
break
}
}
if !present {
delete(mc.marked, mark)
}
}
}
// IsMarked checks whether the given uid has been marked
func (mc *controller) IsMarked(uid models.UID) bool {
_, marked := mc.marked[uid]
return marked
}
// Marked returns the uids of all marked messages
func (mc *controller) Marked() []models.UID {
mc.removeStaleUID()
marked := make([]models.UID, len(mc.marked))
i := 0
for uid := range mc.marked {
marked[i] = uid
i++
}
return marked
}
// IsVisualMark indicates whether visual marking mode is enabled.
func (mc *controller) IsVisualMark() bool {
return mc.visualMarkMode
}
// ToggleVisualMark enters or leaves the visual marking mode
func (mc *controller) ToggleVisualMark(clear bool) {
mc.visualMarkMode = !mc.visualMarkMode
if mc.visualMarkMode {
// just entered visual mode, reset whatever marking was already done
if clear {
mc.resetMark()
}
uids := mc.uidProvider.Uids()
if idx := mc.uidProvider.SelectedIndex(); idx >= 0 && idx < len(uids) {
mc.visualStartUID = uids[idx]
mc.marked[mc.visualStartUID] = struct{}{}
mc.visualBase = make(map[models.UID]struct{})
for key, value := range mc.marked {
mc.visualBase[key] = value
}
}
}
}
// ClearVisualMark leaves the visual marking mode and resets any marking
func (mc *controller) ClearVisualMark() {
mc.resetMark()
mc.visualMarkMode = false
mc.visualStartUID = ""
}
// UpdateVisualMark updates the index with the currently selected message
func (mc *controller) UpdateVisualMark() {
if !mc.visualMarkMode {
// nothing to do
return
}
startIdx := mc.visualStartIdx()
if startIdx < 0 {
// something deleted the startuid, abort the marking process
mc.ClearVisualMark()
return
}
selectedIdx := mc.uidProvider.SelectedIndex()
if selectedIdx < 0 {
return
}
uids := mc.uidProvider.Uids()
var visUids []models.UID
if selectedIdx > startIdx {
visUids = uids[startIdx : selectedIdx+1]
} else {
visUids = uids[selectedIdx : startIdx+1]
}
mc.marked = make(map[models.UID]struct{})
for uid := range mc.visualBase {
mc.marked[uid] = struct{}{}
}
for _, uid := range visUids {
mc.marked[uid] = struct{}{}
}
}
// returns the index of needle in haystack or -1 if not found
func (mc *controller) visualStartIdx() int {
for idx, u := range mc.uidProvider.Uids() {
if u == mc.visualStartUID {
return idx
}
}
return -1
}
+140
View File
@@ -0,0 +1,140 @@
package marker_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/models"
)
// mockUidProvider implements the UidProvider interface and mocks the message
// store for testing
type mockUidProvider struct {
uids []models.UID
idx int
}
func (mock *mockUidProvider) Uids() []models.UID {
return mock.uids
}
func (mock *mockUidProvider) SelectedIndex() int {
return mock.idx
}
func createMarker() (marker.Marker, *mockUidProvider) {
uidProvider := &mockUidProvider{
uids: []models.UID{"1", "2", "3", "4"},
idx: 1,
}
m := marker.New(uidProvider)
return m, uidProvider
}
func TestMarker_MarkUnmark(t *testing.T) {
m, _ := createMarker()
uid := models.UID("4")
m.Mark(uid)
if !m.IsMarked(uid) {
t.Errorf("Marking failed")
}
m.Unmark(uid)
if m.IsMarked(uid) {
t.Errorf("Unmarking failed")
}
}
func TestMarker_ToggleMark(t *testing.T) {
m, _ := createMarker()
uid := models.UID("4")
if m.IsMarked(uid) {
t.Errorf("ToggleMark: uid should not be marked")
}
m.ToggleMark(uid)
if !m.IsMarked(uid) {
t.Errorf("ToggleMark: uid should be marked")
}
m.ToggleMark(uid)
if m.IsMarked(uid) {
t.Errorf("ToggleMark: uid should not be marked")
}
}
func TestMarker_Marked(t *testing.T) {
m, _ := createMarker()
expected := map[models.UID]struct{}{
"1": {},
"4": {},
}
for uid := range expected {
m.Mark(uid)
}
got := m.Marked()
if len(expected) != len(got) {
t.Errorf("Marked: expected len of %d but got %d", len(expected), len(got))
}
for _, uid := range got {
if _, ok := expected[uid]; !ok {
t.Errorf("Marked: received uid %q as marked but it should not be", uid)
}
}
}
func TestMarker_VisualMode(t *testing.T) {
m, up := createMarker()
// activate visual mode
m.ToggleVisualMark(false)
// marking should now fail silently because we're in visual mode
m.Mark("1")
if m.IsMarked("1") {
t.Errorf("marking in visual mode should not work")
}
// move selection index to last item
up.idx = len(up.uids) - 1
m.UpdateVisualMark()
expectedMarked := []models.UID{"2", "3", "4"}
for _, uidMarked := range expectedMarked {
if !m.IsMarked(uidMarked) {
t.Logf("expected: %#v, got: %#v", expectedMarked, m.Marked())
t.Errorf("updatevisual: uid %v should be marked in visual mode", uidMarked)
}
}
// clear all
m.ClearVisualMark()
if len(m.Marked()) > 0 {
t.Errorf("no uids should be marked after clearing visual mark")
}
// test remark
m.Remark()
for _, uidMarked := range expectedMarked {
if !m.IsMarked(uidMarked) {
t.Errorf("remark: uid %v should be marked in visual mode", uidMarked)
}
}
}
func TestMarker_MarkOutOfBound(t *testing.T) {
m, _ := createMarker()
outOfBoundUid := models.UID("100")
m.Mark(outOfBoundUid)
for _, markedUid := range m.Marked() {
if markedUid == outOfBoundUid {
t.Errorf("out-of-bound uid should not be marked")
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package lib
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
_ "github.com/emersion/go-message/charset"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
// This is an abstraction for viewing a message with semi-transparent PGP
// support.
type MessageView interface {
// Returns the MessageInfo for this message
MessageInfo() *models.MessageInfo
// Returns the BodyStructure for this message
BodyStructure() *models.BodyStructure
// Returns the message store that this message was originally sourced from
Store() *MessageStore
// Fetches the full message
FetchFull(cb func(io.Reader))
// Fetches a specific body part for this message
FetchBodyPart(part []int, cb func(io.Reader))
MessageDetails() *models.MessageDetails
// SeenFlagSet returns true if the "seen" flag has been set
SeenFlagSet() bool
}
func usePGP(info *models.BodyStructure) bool {
if info == nil {
return false
}
if info.MIMEType == "application" {
if info.MIMESubType == "pgp-encrypted" ||
info.MIMESubType == "pgp-signature" {
return true
}
}
for _, part := range info.Parts {
if usePGP(part) {
return true
}
}
return false
}
type MessageStoreView struct {
messageInfo *models.MessageInfo
messageStore *MessageStore
message []byte
details *models.MessageDetails
bodyStructure *models.BodyStructure
setSeen bool
}
func NewMessageStoreView(messageInfo *models.MessageInfo, setSeen bool,
store *MessageStore, pgp crypto.Provider, decryptKeys openpgp.PromptFunction,
innerCb func(MessageView, error),
) {
cb := func(msv MessageView, err error) {
if msv != nil && setSeen && err == nil &&
!messageInfo.Flags.Has(models.SeenFlag) {
store.Flag([]models.UID{messageInfo.Uid}, models.SeenFlag, true, nil)
}
innerCb(msv, err)
}
if messageInfo == nil {
// Call nils to the callback, the split view will use this to
// display an empty view
cb(nil, nil)
return
}
msv := &MessageStoreView{
messageInfo, store,
nil, nil, messageInfo.BodyStructure,
setSeen,
}
if usePGP(messageInfo.BodyStructure) {
msv.FetchFull(func(fm io.Reader) {
reader := rfc822.NewCRLFReader(fm)
md, err := pgp.Decrypt(reader, decryptKeys)
if err != nil {
cb(nil, err)
return
}
msv.message, err = io.ReadAll(md.Body)
if err != nil {
cb(nil, err)
return
}
decrypted, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message))
if err != nil {
cb(nil, err)
return
}
bs, err := rfc822.ParseEntityStructure(decrypted)
if rfc822.IsMultipartError(err) {
log.Warnf("MessageView: %v", err)
bs = rfc822.CreateTextPlainBody()
} else if err != nil {
cb(nil, err)
return
}
msv.bodyStructure = bs
msv.details = md
cb(msv, nil)
})
} else {
cb(msv, nil)
}
}
func (msv *MessageStoreView) SeenFlagSet() bool {
return msv.setSeen
}
func (msv *MessageStoreView) MessageInfo() *models.MessageInfo {
return msv.messageInfo
}
func (msv *MessageStoreView) BodyStructure() *models.BodyStructure {
return msv.bodyStructure
}
func (msv *MessageStoreView) Store() *MessageStore {
return msv.messageStore
}
func (msv *MessageStoreView) MessageDetails() *models.MessageDetails {
return msv.details
}
func (msv *MessageStoreView) FetchFull(cb func(io.Reader)) {
if msv.message == nil && msv.messageStore != nil {
msv.messageStore.FetchFull([]models.UID{msv.messageInfo.Uid},
func(fm *types.FullMessage) {
cb(fm.Content.Reader)
})
return
}
cb(bytes.NewReader(msv.message))
}
func (msv *MessageStoreView) FetchBodyPart(part []int, cb func(io.Reader)) {
if msv.message == nil && msv.messageStore != nil {
msv.messageStore.FetchBodyPart(msv.messageInfo.Uid, part, cb)
return
}
buf := bytes.NewBuffer(msv.message)
msg, err := rfc822.ReadMessage(buf)
if err != nil {
panic(err)
}
reader, err := rfc822.FetchEntityPartReader(msg, part)
if err != nil {
errMsg := fmt.Errorf("Failed to fetch message part: %w", err)
log.Errorf(errMsg.Error())
if msv.message != nil {
log.Warnf("Displaying raw message part")
reader = bytes.NewReader(msv.message)
} else {
reader = strings.NewReader(errMsg.Error())
}
}
cb(reader)
}
+1039
View File
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
type Mode int
const (
MODE_READ_ONLY Mode = C.NOTMUCH_DATABASE_MODE_READ_ONLY
MODE_READ_WRITE Mode = C.NOTMUCH_DATABASE_MODE_READ_WRITE
)
type Database struct {
// The path to the notmuch database. If Path is the empty string, the
// location will be found in the following order:
//
// 1. The value of the environment variable NOTMUCH_DATABASE
// 2. From the config file specified by Config
// 3. From the Profile specified by profile, given by
// $XDG_DATA_HOME/notmuch/$PROFILE
Path string
// The path to the notmuch configuration file to use.
Config string
// If FindConfig is true, libnotmuch will attempt to locate a suitable
// configuration file in the following order:
//
// 1. The value of the environment variable NOTMUCH_CONFIG
// 2. $XDG_CONFIG_HOME/notmuch/
// 3. $HOME/.notmuch-config
//
// If not configuration file is found, a STATUS_NO_CONFIG error will be
// returned
FindConfig bool
// The profile to use. If Profile is non-empty, the value will be
// appended to the paths determined for Config and Path. If Profile is
// the empty string, the profile will be determined in the following
// order:
//
// 1. The value of the environment variable NOTMUCH_PROFILE
// 2. "default" if Config and/or Path are a directory, "" if they are a
// filepath
Profile string
db *C.notmuch_database_t
open bool
}
// Create creates a notmuch database at the Path
func (db *Database) Create() error {
var cdb *C.notmuch_database_t
var cPath *C.char
defer C.free(unsafe.Pointer(cPath))
if db.Path != "" {
cPath = C.CString(db.Path)
}
err := errorWrap(C.notmuch_database_create(cPath, &cdb)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return err
}
db.db = cdb
return nil
}
// Open opens the database with the given mode. Caller must call Close when done
// to commit changes and free resources
func (db *Database) Open(mode Mode) error {
var (
cPath *C.char
cConfig *C.char
cProfile *C.char
cErr *C.char
)
defer C.free(unsafe.Pointer(cPath))
defer C.free(unsafe.Pointer(cConfig))
defer C.free(unsafe.Pointer(cProfile))
defer C.free(unsafe.Pointer(cErr))
if db.Path != "" {
cPath = C.CString(db.Path)
}
if !db.FindConfig {
cConfig = C.CString(db.Config)
}
if db.Profile != "" {
cProfile = C.CString(db.Profile)
}
cmode := C.notmuch_database_mode_t(mode)
var cdb *C.notmuch_database_t
// gocritic:dupSubExpr throws an issue here no matter how we call this
// function
err := errorWrap(
C.notmuch_database_open_with_config(
cPath, cmode, cConfig, cProfile, &cdb, &cErr, //nolint:gocritic // see above
),
)
if err != nil {
return err
}
db.db = cdb
db.open = true
return nil
}
// Reopen an open notmuch database, usually with a different mode
func (db *Database) Reopen(mode Mode) error {
cmode := C.notmuch_database_mode_t(mode)
return errorWrap(C.notmuch_database_reopen(db.db, cmode))
}
// Close commits changes and closes the database, freeing any resources
// associated with it
func (db *Database) Close() error {
if !db.open {
return nil
}
err := errorWrap(C.notmuch_database_close(db.db))
if err != nil {
return err
}
err = errorWrap(C.notmuch_database_destroy(db.db))
if err != nil {
return err
}
db.open = false
return nil
}
// LastStatus returns the last status string for the database
func (db *Database) LastStatus() string {
cStatus := C.notmuch_database_status_string(db.db)
defer C.free(unsafe.Pointer(cStatus))
return C.GoString(cStatus)
}
func (db *Database) Compact(backupPath string) error {
if backupPath == "" {
return fmt.Errorf("must have backup path before compacting")
}
var cBackupPath *C.char
defer C.free(unsafe.Pointer(cBackupPath))
return errorWrap(C.notmuch_database_compact_db(db.db, cBackupPath, nil, nil))
}
// Return the resolved path to the notmuch database
func (db *Database) ResolvedPath() string {
cPath := C.notmuch_database_get_path(db.db)
return C.GoString(cPath)
}
// NeedsUpgrade reports if the database must be upgraded before a write
// operation can be safely performed
func (db *Database) NeedsUpgrade() bool {
return C.notmuch_database_needs_upgrade(db.db) == 1
}
// Indicate the beginning of an atomic operation
func (db *Database) BeginAtomic() error {
return errorWrap(C.notmuch_database_begin_atomic(db.db))
}
// Indicate the end of an atomic operation
func (db *Database) EndAtomic() error {
return errorWrap(C.notmuch_database_end_atomic(db.db))
}
// Returns the UUID and LastMod of the notmuch database
func (db *Database) Revision() (string, uint64) {
var uuid *C.char
defer C.free(unsafe.Pointer(uuid))
lastmod := uint64(C.notmuch_database_get_revision(db.db, &uuid)) //nolint:gocritic // see note in notmuch.go
return C.GoString(uuid), lastmod
}
// Returns a Directory object relative to the path of the Database
func (db *Database) Directory(relativePath string) (Directory, error) {
var result Directory
if relativePath == "" {
return result, fmt.Errorf("path can't be empty")
}
var (
dir *C.notmuch_directory_t
cPath *C.char
)
cPath = C.CString(relativePath)
defer C.free(unsafe.Pointer(cPath))
err := errorWrap(C.notmuch_database_get_directory(db.db, cPath, &dir)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return result, err
}
result.dir = dir
return result, nil
}
// IndexFile indexes a file with path relative to the database path, or an
// absolute path which share a common ancestor as the database path
func (db *Database) IndexFile(path string) (Message, error) {
var (
cPath *C.char
msg *C.notmuch_message_t
)
cPath = C.CString(path)
defer C.free(unsafe.Pointer(cPath))
err := errorWrap(C.notmuch_database_index_file(db.db, cPath, nil, &msg)) //nolint:gocritic // see note in notmuch.go
switch {
case errors.Is(err, STATUS_DUPLICATE_MESSAGE_ID):
break
case err != nil:
return Message{}, err
}
message := Message{
message: msg,
}
return message, nil
}
// Remove a file from the database. If this is the last file associated with a
// message, the message will be removed from the database.
func (db *Database) RemoveFile(path string) error {
cPath := C.CString(path)
defer C.free(unsafe.Pointer(cPath))
return errorWrap(C.notmuch_database_remove_message(db.db, cPath))
}
// FindMessageByID finds a message by the Message-ID header field value
func (db *Database) FindMessageByID(id string) (Message, error) {
var (
cID *C.char
msg *C.notmuch_message_t
)
cID = C.CString(id)
defer C.free(unsafe.Pointer(cID))
err := errorWrap(C.notmuch_database_find_message(db.db, cID, &msg)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return Message{}, err
}
message := Message{
message: msg,
}
return message, nil
}
// FindMessageByFilename finds a message by filename
func (db *Database) FindMessageByFilename(filename string) (Message, error) {
var (
cFilename *C.char
msg *C.notmuch_message_t
)
cFilename = C.CString(filename)
defer C.free(unsafe.Pointer(cFilename))
err := errorWrap(C.notmuch_database_find_message_by_filename(db.db, cFilename, &msg)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return Message{}, err
}
if msg == nil {
return Message{}, fmt.Errorf("couldn't find message by filename: %s", filename)
}
message := Message{
message: msg,
}
return message, nil
}
// Tags returns a slice of all tags in the database
func (db *Database) Tags() []string {
cTags := C.notmuch_database_get_all_tags(db.db)
defer C.notmuch_tags_destroy(cTags)
tags := []string{}
for C.notmuch_tags_valid(cTags) > 0 {
tag := C.notmuch_tags_get(cTags)
tags = append(tags, C.GoString(tag))
C.notmuch_tags_move_to_next(cTags)
}
return tags
}
// Create a new Query
func (db *Database) Query(query string) (Query, error) {
cQuery := C.CString(query)
defer C.free(unsafe.Pointer(cQuery))
nmQuery := C.notmuch_query_create(db.db, cQuery)
if nmQuery == nil {
return Query{}, STATUS_OUT_OF_MEMORY
}
q := Query{
query: nmQuery,
}
return q, nil
}
+64
View File
@@ -0,0 +1,64 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <notmuch.h>
*/
import "C"
import "time"
type Directory struct {
dir *C.notmuch_directory_t
}
func (dir *Directory) SetModifiedTime(t time.Time) error {
cTime := C.time_t(t.Unix())
return errorWrap(C.notmuch_directory_set_mtime(dir.dir, cTime))
}
func (dir *Directory) ModifiedTime() time.Time {
cTime := C.notmuch_directory_get_mtime(dir.dir)
return time.Unix(int64(cTime), 0)
}
func (dir *Directory) Filenames() []string {
cFilenames := C.notmuch_directory_get_child_files(dir.dir)
defer C.notmuch_filenames_destroy(cFilenames)
filenames := []string{}
for C.notmuch_filenames_valid(cFilenames) > 0 {
filename := C.notmuch_filenames_get(cFilenames)
filenames = append(filenames, C.GoString(filename))
C.notmuch_filenames_move_to_next(cFilenames)
}
return filenames
}
func (dir *Directory) Directories() []string {
cFilenames := C.notmuch_directory_get_child_directories(dir.dir)
defer C.notmuch_filenames_destroy(cFilenames)
filenames := []string{}
for C.notmuch_filenames_valid(cFilenames) > 0 {
filename := C.notmuch_filenames_get(cFilenames)
filenames = append(filenames, C.GoString(filename))
C.notmuch_filenames_move_to_next(cFilenames)
}
return filenames
}
// Delete deletes a directory document from the database and destroys
// the underlying object. Any child directories and files must have been
// deleted firs the caller
func (dir *Directory) Delete() error {
return errorWrap(C.notmuch_directory_delete(dir.dir))
}
func (dir *Directory) Close() {
C.notmuch_directory_destroy(dir.dir)
}
+55
View File
@@ -0,0 +1,55 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <notmuch.h>
*/
import "C"
// Status codes used for the return values of most functions
type Status int
const (
STATUS_SUCCESS Status = C.NOTMUCH_STATUS_SUCCESS
STATUS_OUT_OF_MEMORY Status = C.NOTMUCH_STATUS_OUT_OF_MEMORY
STATUS_READ_ONLY_DATABASE Status = C.NOTMUCH_STATUS_READ_ONLY_DATABASE
STATUS_XAPIAN_EXCEPTION Status = C.NOTMUCH_STATUS_XAPIAN_EXCEPTION
STATUS_FILE_ERROR Status = C.NOTMUCH_STATUS_FILE_ERROR
STATUS_FILE_NOT_EMAIL Status = C.NOTMUCH_STATUS_FILE_NOT_EMAIL
STATUS_DUPLICATE_MESSAGE_ID Status = C.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID
STATUS_NULL_POINTER Status = C.NOTMUCH_STATUS_NULL_POINTER
STATUS_TAG_TOO_LONG Status = C.NOTMUCH_STATUS_TAG_TOO_LONG
STATUS_UNBALANCED_FREEZE_THAW Status = C.NOTMUCH_STATUS_UNBALANCED_FREEZE_THAW
STATUS_UNBALANCED_ATOMIC Status = C.NOTMUCH_STATUS_UNBALANCED_ATOMIC
STATUS_UNSUPPORTED_OPERATION Status = C.NOTMUCH_STATUS_UNSUPPORTED_OPERATION
STATUS_UPGRADE_REQUIRED Status = C.NOTMUCH_STATUS_UPGRADE_REQUIRED
STATUS_PATH_ERROR Status = C.NOTMUCH_STATUS_PATH_ERROR
STATUS_IGNORED Status = C.NOTMUCH_STATUS_IGNORED
STATUS_ILLEGAL_ARGUMENT Status = C.NOTMUCH_STATUS_ILLEGAL_ARGUMENT
STATUS_MALFORMED_CRYPTO_PROTOCOL Status = C.NOTMUCH_STATUS_MALFORMED_CRYPTO_PROTOCOL
STATUS_FAILED_CRYPTO_CONTEXT_CREATION Status = C.NOTMUCH_STATUS_FAILED_CRYPTO_CONTEXT_CREATION
STATUS_UNKNOWN_CRYPTO_PROTOCOL Status = C.NOTMUCH_STATUS_UNKNOWN_CRYPTO_PROTOCOL
STATUS_NO_CONFIG Status = C.NOTMUCH_STATUS_NO_CONFIG
STATUS_NO_DATABASE Status = C.NOTMUCH_STATUS_NO_DATABASE
STATUS_DATABASE_EXISTS Status = C.NOTMUCH_STATUS_DATABASE_EXISTS
STATUS_BAD_QUERY_SYNTAX Status = C.NOTMUCH_STATUS_BAD_QUERY_SYNTAX
STATUS_NO_MAIL_ROOT Status = C.NOTMUCH_STATUS_NO_MAIL_ROOT
STATUS_CLOSED_DATABASE Status = C.NOTMUCH_STATUS_CLOSED_DATABASE
)
func (s Status) Error() string {
status := C.notmuch_status_to_string(C.notmuch_status_t(s))
return C.GoString(status)
}
func errorWrap(st C.notmuch_status_t) error {
if Status(st) == STATUS_SUCCESS {
return nil
}
return Status(st)
}
+260
View File
@@ -0,0 +1,260 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
*/
import "C"
import (
"time"
"unsafe"
)
type Message struct {
message *C.notmuch_message_t
}
// Close frees resources associated with the message
func (m *Message) Close() {
C.notmuch_message_destroy(m.message)
}
// ID returns the message ID
func (m *Message) ID() string {
cID := C.notmuch_message_get_message_id(m.message)
return C.GoString(cID)
}
// ThreadID returns the thread ID of the message
func (m *Message) ThreadID() string {
cID := C.notmuch_message_get_thread_id(m.message)
return C.GoString(cID)
}
func (m *Message) Replies() Messages {
cMessages := C.notmuch_message_get_replies(m.message)
return Messages{
messages: cMessages,
}
}
func (m *Message) TotalFiles() int {
return int(C.notmuch_message_count_files(m.message))
}
// Filename returns a single filename associated with the message. If the
// message has multiple filenames, the return value will be arbitrarily chosen
func (m *Message) Filename() string {
cFilename := C.notmuch_message_get_filename(m.message)
return C.GoString(cFilename)
}
func (m *Message) Filenames() []string {
cFilenames := C.notmuch_message_get_filenames(m.message)
defer C.notmuch_filenames_destroy(cFilenames)
filenames := []string{}
for C.notmuch_filenames_valid(cFilenames) > 0 {
filename := C.notmuch_filenames_get(cFilenames)
filenames = append(filenames, C.GoString(filename))
C.notmuch_filenames_move_to_next(cFilenames)
}
return filenames
}
// TODO is this needed?
// func (m *Message) Reindex() error {
//
// }
type Flag int
const (
MESSAGE_FLAG_MATCH Flag = iota
MESSAGE_FLAG_EXCLUDED
MESSAGE_FLAG_GHOST
)
func (m *Message) Flag(flag Flag) (bool, error) {
var ok C.notmuch_bool_t
cFlag := C.notmuch_message_flag_t(flag)
err := errorWrap(C.notmuch_message_get_flag_st(m.message, cFlag, &ok))
if err != nil {
return false, err
}
if ok == 0 {
return false, nil
}
return true, nil
}
// TODO why does this exist??
// func (m *Message) SetFlag(flag Flag) {
//
// }
func (m *Message) Date() time.Time {
cTime := C.notmuch_message_get_date(m.message)
return time.Unix(int64(cTime), 0)
}
func (m *Message) Header(field string) string {
cField := C.CString(field)
defer C.free(unsafe.Pointer(cField))
cHeader := C.notmuch_message_get_header(m.message, cField)
return C.GoString(cHeader)
}
func (m *Message) Tags() []string {
cTags := C.notmuch_message_get_tags(m.message)
defer C.notmuch_tags_destroy(cTags)
tags := []string{}
for C.notmuch_tags_valid(cTags) > 0 {
tag := C.notmuch_tags_get(cTags)
tags = append(tags, C.GoString(tag))
C.notmuch_tags_move_to_next(cTags)
}
return tags
}
func (m *Message) AddTag(tag string) error {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
return errorWrap(C.notmuch_message_add_tag(m.message, cTag))
}
func (m *Message) RemoveTag(tag string) error {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
return errorWrap(C.notmuch_message_remove_tag(m.message, cTag))
}
func (m *Message) RemoveAllTags() error {
return errorWrap(C.notmuch_message_remove_all_tags(m.message))
}
// SyncTagsToMaildirFlags adds/removes the appropriate tags to the maildir
// filename
func (m *Message) SyncTagsToMaildirFlags() error {
return errorWrap(C.notmuch_message_tags_to_maildir_flags(m.message))
}
// SyncMaildirFlagsToTags syncs the current maildir flags to the notmuch tags
func (m *Message) SyncMaildirFlagsToTags() error {
return errorWrap(C.notmuch_message_maildir_flags_to_tags(m.message))
}
func (m *Message) HasMaildirFlag(flag rune) (bool, error) {
var ok C.notmuch_bool_t
err := errorWrap(C.notmuch_message_has_maildir_flag_st(m.message, C.char(flag), &ok))
if err != nil {
return false, err
}
if ok == 0 {
return false, nil
}
return true, nil
}
func (m *Message) Freeze() error {
return errorWrap(C.notmuch_message_freeze(m.message))
}
func (m *Message) Thaw() error {
return errorWrap(C.notmuch_message_thaw(m.message))
}
func (m *Message) Property(key string) (string, error) {
var (
cKey *C.char
cValue *C.char
)
defer C.free(unsafe.Pointer(cKey))
defer C.free(unsafe.Pointer(cValue))
cKey = C.CString(key)
err := errorWrap(C.notmuch_message_get_property(m.message, cKey, &cValue)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return "", err
}
return C.GoString(cValue), nil
}
func (m *Message) AddProperty(key string, value string) error {
var (
cKey *C.char
cValue *C.char
)
defer C.free(unsafe.Pointer(cKey))
defer C.free(unsafe.Pointer(cValue))
cKey = C.CString(key)
cValue = C.CString(value)
return errorWrap(C.notmuch_message_add_property(m.message, cKey, cValue))
}
func (m *Message) RemoveProperty(key string, value string) error {
var (
cKey *C.char
cValue *C.char
)
defer C.free(unsafe.Pointer(cKey))
defer C.free(unsafe.Pointer(cValue))
cKey = C.CString(key)
cValue = C.CString(value)
return errorWrap(C.notmuch_message_remove_property(m.message, cKey, cValue))
}
func (m *Message) RemoveAllProperties(key string) error {
var cKey *C.char
defer C.free(unsafe.Pointer(cKey))
cKey = C.CString(key)
return errorWrap(C.notmuch_message_remove_all_properties(m.message, cKey))
}
func (m *Message) RemoveAllPropertiesWithPrefix(prefix string) error {
var cPrefix *C.char
defer C.free(unsafe.Pointer(cPrefix))
cPrefix = C.CString(prefix)
return errorWrap(C.notmuch_message_remove_all_properties_with_prefix(m.message, cPrefix))
}
func (m *Message) Properties(key string, exact bool) *Properties {
var (
cKey *C.char
cExact C.int
)
defer C.free(unsafe.Pointer(cKey))
if exact {
cExact = 1
}
cKey = C.CString(key)
props := C.notmuch_message_get_properties(m.message, cKey, cExact)
return &Properties{
properties: props,
}
}
func (m *Message) CountProperties(key string) (int, error) {
var (
cKey *C.char
cCount C.uint
)
defer C.free(unsafe.Pointer(cKey))
cKey = C.CString(key)
err := errorWrap(C.notmuch_message_count_properties(m.message, cKey, &cCount))
if err != nil {
return 0, err
}
return int(cCount), nil
}
+58
View File
@@ -0,0 +1,58 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <notmuch.h>
*/
import "C"
type Messages struct {
message *C.notmuch_message_t
messages *C.notmuch_messages_t
}
// Next advances the Messages iterator to the next message. Next returns false if
// no more messages are available
func (m *Messages) Next() bool {
if C.notmuch_messages_valid(m.messages) == 0 {
return false
}
m.message = C.notmuch_messages_get(m.messages)
C.notmuch_messages_move_to_next(m.messages)
return true
}
// Message returns the current message in the iterator
func (m *Messages) Message() Message {
return Message{
message: m.message,
}
}
// Close frees memory associated with a Messages iterator. This method is not
// strictly necessary to call, as the resources will be freed when the Query
// associated with the Messages object is freed.
func (m *Messages) Close() {
C.notmuch_messages_destroy(m.messages)
}
// Tags returns a slice of all tags in the message list. WARNING: After calling
// tags, the message list can no longer be iterated; a new list must be created
// to iterate after calling Tags
func (m *Messages) Tags() []string {
cTags := C.notmuch_messages_collect_tags(m.messages)
defer C.notmuch_tags_destroy(cTags)
tags := []string{}
for C.notmuch_tags_valid(cTags) > 0 {
tag := C.notmuch_tags_get(cTags)
tags = append(tags, C.GoString(tag))
C.notmuch_tags_move_to_next(cTags)
}
return tags
}
+32
View File
@@ -0,0 +1,32 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
#if !LIBNOTMUCH_CHECK_VERSION(5, 6, 0)
#error "aerc requires libnotmuch.so.5.6 or later"
#endif
*/
import "C"
import "fmt"
const (
MAJOR_VERSION = C.LIBNOTMUCH_MAJOR_VERSION
MINOR_VERSION = C.LIBNOTMUCH_MINOR_VERSION
MICRO_VERSION = C.LIBNOTMUCH_MICRO_VERSION
)
func Version() string {
return fmt.Sprintf("%d.%d.%d", MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION)
}
// NOTE: Any CGO call which passes a reference to a pointer (**object) will fail
// gocritic:dupSubExpr. All of these calls are set to be ignored by the linter
// Reference: https://github.com/go-critic/go-critic/issues/897
+39
View File
@@ -0,0 +1,39 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <notmuch.h>
*/
import "C"
type Properties struct {
key *C.char
value *C.char
properties *C.notmuch_message_properties_t
}
// Next advances the Properties iterator to the next property. Next returns false if
// no more properties are available
func (p *Properties) Next() bool {
if C.notmuch_message_properties_valid(p.properties) == 0 {
return false
}
p.key = C.notmuch_message_properties_key(p.properties)
p.value = C.notmuch_message_properties_value(p.properties)
C.notmuch_message_properties_move_to_next(p.properties)
return true
}
// Returns the key of the current iterator location
func (p *Properties) Key() string {
return C.GoString(p.key)
}
func (p *Properties) Value() string {
return C.GoString(p.value)
}
+120
View File
@@ -0,0 +1,120 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
*/
import "C"
import "unsafe"
type ExcludeMode int
const (
EXCLUDE_FLAG ExcludeMode = C.NOTMUCH_EXCLUDE_FLAG
EXCLUDE_TRUE ExcludeMode = C.NOTMUCH_EXCLUDE_TRUE
EXCLUDE_FALSE ExcludeMode = C.NOTMUCH_EXCLUDE_FALSE
EXCLUDE_ALL ExcludeMode = C.NOTMUCH_EXCLUDE_ALL
)
type SortMode int
const (
SORT_OLDEST_FIRST SortMode = C.NOTMUCH_SORT_OLDEST_FIRST
SORT_NEWEST_FIRST SortMode = C.NOTMUCH_SORT_NEWEST_FIRST
SORT_MESSAGE_ID SortMode = C.NOTMUCH_SORT_MESSAGE_ID
SORT_UNSORTED SortMode = C.NOTMUCH_SORT_UNSORTED
)
type Query struct {
query *C.notmuch_query_t
}
// Close frees resources associated with a query. Closing a query release all
// resources associated with any underlying search (Threads, Messages, etc)
func (q *Query) Close() {
C.notmuch_query_destroy(q.query)
}
// Return the string of the query
func (q *Query) String() string {
return C.GoString(C.notmuch_query_get_query_string(q.query))
}
// Returns the Database associated with the query. The Path, Config, and Profile
// values will not be set on the returned valued
func (q *Query) Database() Database {
db := C.notmuch_query_get_database(q.query)
return Database{
db: db,
}
}
// Exclude sets the exclusion mode.
func (q *Query) Exclude(val ExcludeMode) {
cVal := C.notmuch_exclude_t(val)
C.notmuch_query_set_omit_excluded(q.query, cVal)
}
// Sort sets the sort order of the results
func (q *Query) Sort(sort SortMode) {
cVal := C.notmuch_sort_t(sort)
C.notmuch_query_set_sort(q.query, cVal)
}
// SortMode returns the current sort order of the results
func (q *Query) SortMode() SortMode {
return SortMode(C.notmuch_query_get_sort(q.query))
}
// ExcludeTag adds a tag to exclude from the results
func (q *Query) ExcludeTag(tag string) error {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
return errorWrap(C.notmuch_query_add_tag_exclude(q.query, cTag))
}
// Threads returns an iterator over the threads that match the query
func (q *Query) Threads() (Threads, error) {
var cThreads *C.notmuch_threads_t
err := errorWrap(C.notmuch_query_search_threads(q.query, &cThreads)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return Threads{}, err
}
threads := Threads{
threads: cThreads,
}
return threads, nil
}
// Messages returns an iterator over the messages that match the query
func (q *Query) Messages() (Messages, error) {
var cMessages *C.notmuch_messages_t
err := errorWrap(C.notmuch_query_search_messages(q.query, &cMessages)) //nolint:gocritic // see note in notmuch.go
if err != nil {
return Messages{}, err
}
messages := Messages{
messages: cMessages,
}
return messages, nil
}
// CountMessages returns the number of messages matching the query
func (q *Query) CountMessages() (int, error) {
var count C.uint
err := errorWrap(C.notmuch_query_count_messages(q.query, &count))
return int(count), err
}
// CountThreads returns the number of threads matching the query
func (q *Query) CountThreads() (int, error) {
var count C.uint
err := errorWrap(C.notmuch_query_count_threads(q.query, &count))
return int(count), err
}
+99
View File
@@ -0,0 +1,99 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
*/
import "C"
import "time"
type Thread struct {
thread *C.notmuch_thread_t
}
// ID returns the thread ID
func (t *Thread) ID() string {
cID := C.notmuch_thread_get_thread_id(t.thread)
return C.GoString(cID)
}
// TotalMessages returns the total number of messages in the thread
func (t *Thread) TotalMessages() int {
return int(C.notmuch_thread_get_total_messages(t.thread))
}
// TotalMessages returns the total number of files in the thread
func (t *Thread) TotalFiles() int {
return int(C.notmuch_thread_get_total_files(t.thread))
}
// TopLevelMessages returns an iterator over the top level messages in the
// thread. Messages are sorted oldest-first
func (t *Thread) TopLevelMessages() Messages {
cMessages := C.notmuch_thread_get_toplevel_messages(t.thread)
return Messages{
messages: cMessages,
}
}
// Messages returns an iterator over the messages in the thread. Messages are
// sorted oldest-first
func (t *Thread) Messages() Messages {
cMessages := C.notmuch_thread_get_messages(t.thread)
return Messages{
messages: cMessages,
}
}
// Matches returns the number of messages in the thread that matched the query
func (t *Thread) Matches() int {
return int(C.notmuch_thread_get_matched_messages(t.thread))
}
// Returns a string of authors of the thread
func (t *Thread) Authors() string {
cAuthors := C.notmuch_thread_get_authors(t.thread)
return C.GoString(cAuthors)
}
// Returns the subject of the thread
func (t *Thread) Subject() string {
cSubject := C.notmuch_thread_get_subject(t.thread)
return C.GoString(cSubject)
}
// Returns the sent-date of the oldest message in the thread
func (t *Thread) OldestDate() time.Time {
cTime := C.notmuch_thread_get_oldest_date(t.thread)
return time.Unix(int64(cTime), 0)
}
// Returns the sent-date of the newest message in the thread
func (t *Thread) NewestDate() time.Time {
cTime := C.notmuch_thread_get_newest_date(t.thread)
return time.Unix(int64(cTime), 0)
}
// Tags returns a slice of all tags in the thread
func (t *Thread) Tags() []string {
cTags := C.notmuch_thread_get_tags(t.thread)
defer C.notmuch_tags_destroy(cTags)
tags := []string{}
for C.notmuch_tags_valid(cTags) > 0 {
tag := C.notmuch_tags_get(cTags)
tags = append(tags, C.GoString(tag))
C.notmuch_tags_move_to_next(cTags)
}
return tags
}
func (t *Thread) Close() {
C.notmuch_thread_destroy(t.thread)
}
+44
View File
@@ -0,0 +1,44 @@
//go:build notmuch
// +build notmuch
package notmuch
/*
#cgo LDFLAGS: -lnotmuch
#include <stdlib.h>
#include <notmuch.h>
*/
import "C"
// Threads is an iterator over a set of threads.
type Threads struct {
thread *C.notmuch_thread_t
threads *C.notmuch_threads_t
}
// Next advances the Threads iterator to the next thread. Next returns false if
// no more threads are available
func (t *Threads) Next() bool {
if C.notmuch_threads_valid(t.threads) == 0 {
return false
}
t.thread = C.notmuch_threads_get(t.threads)
C.notmuch_threads_move_to_next(t.threads)
return true
}
// Thread returns the current thread in the iterator
func (t *Threads) Thread() Thread {
return Thread{
thread: t.thread,
}
}
// Close frees memory associated with a Threads iterator. This method is not
// strictly necessary to call, as the resources will be freed when the Query
// associated with the Threads object is freed.
func (t *Threads) Close() {
C.notmuch_threads_destroy(t.threads)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build notmuch
// +build notmuch
package lib
import "git.sr.ht/~rjarry/aerc/lib/notmuch"
func NotmuchVersion() (string, bool) {
return notmuch.Version(), true
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !notmuch
// +build !notmuch
package lib
func NotmuchVersion() (string, bool) {
return "", false
}
+43
View File
@@ -0,0 +1,43 @@
package lib
import (
"context"
"fmt"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-sasl"
"golang.org/x/oauth2"
)
type OAuthBearer struct {
OAuth2 *oauth2.Config
Enabled bool
}
func (c *OAuthBearer) ExchangeRefreshToken(refreshToken string) (*oauth2.Token, error) {
token := new(oauth2.Token)
token.RefreshToken = refreshToken
token.TokenType = "Bearer"
return c.OAuth2.TokenSource(context.TODO(), token).Token()
}
func (c *OAuthBearer) Authenticate(username string, password string, client *client.Client) error {
if ok, err := client.SupportAuth(sasl.OAuthBearer); err != nil || !ok {
return fmt.Errorf("OAuthBearer not supported %w", err)
}
if c.OAuth2.Endpoint.TokenURL != "" {
token, err := c.ExchangeRefreshToken(password)
if err != nil {
return err
}
password = token.AccessToken
}
saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{
Username: username,
Token: password,
})
return client.Authenticate(saslClient)
}
+55
View File
@@ -0,0 +1,55 @@
package lib
import (
"fmt"
"os/exec"
"runtime"
"strings"
"git.sr.ht/~rjarry/go-opt/v2"
"github.com/danwakefield/fnmatch"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
)
func XDGOpenMime(
uri string, mimeType string, args string,
) error {
if len(args) == 0 {
// no explicit command provided, lookup opener from mime type
for _, o := range config.Openers {
if fnmatch.Match(o.Mime, mimeType, 0) {
args = o.Args
break
}
}
}
if len(args) == 0 {
// no opener defined in config, fallback to default
if runtime.GOOS == "darwin" {
args = "open"
} else {
args = "xdg-open"
}
}
// Escape URI special characters
uri = opt.QuoteArg(uri)
if strings.Contains(args, "{}") {
// found {} placeholder in args, replace with uri
args = strings.Replace(args, "{}", uri, 1)
} else {
// no {} placeholder in args, add uri at the end
args = args + " " + uri
}
log.Tracef("running command: %v", args)
cmd := exec.Command("sh", "-c", args)
out, err := cmd.CombinedOutput()
log.Debugf("command: %v exited. err=%v out=%s", args, err, out)
if err != nil {
return fmt.Errorf("%v: %w", args, err)
}
return nil
}
+138
View File
@@ -0,0 +1,138 @@
package pama
import (
"crypto/rand"
"encoding/base64"
"fmt"
mathrand "math/rand"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) CurrentProject() (p models.Project, err error) {
store := m.store()
name, err := store.CurrentName()
if name == "" || err != nil {
log.Errorf("failed to get current name: %v", storeErr(err))
err = fmt.Errorf("no current project set. " +
"Run :patch init first")
return
}
names, err := store.Names()
if err != nil {
err = storeErr(err)
return
}
notFound := true
for _, s := range names {
if s == name {
notFound = !notFound
break
}
}
if notFound {
err = fmt.Errorf("project '%s' does not exist anymore. "+
"Run :patch init or :patch switch", name)
return
}
p, err = store.Current()
if err != nil {
err = storeErr(err)
}
return
}
func (m PatchManager) CurrentPatches() ([]string, error) {
c, err := m.CurrentProject()
if err != nil {
return nil, err
}
return models.Commits(c.Commits).Tags(), nil
}
func (m PatchManager) Head(p models.Project) (string, error) {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return "", revErr(err)
}
return rc.Head()
}
func (m PatchManager) Clean(p models.Project) bool {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
log.Errorf("could not get revctl: %v", revErr(err))
return false
}
return rc.Clean()
}
func (m PatchManager) ApplyCmd(p models.Project) (string, error) {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return "", revErr(err)
}
return rc.ApplyCmd(), nil
}
func generateTag(n int) (string, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func makeUnique(s string) string {
tag, err := generateTag(4)
if err != nil {
return fmt.Sprintf("%s_%d", s, mathrand.Uint32())
}
return fmt.Sprintf("%s_%s", s, tag)
}
// ApplyUpdate is called after the commits have been applied with the
// ApplyCmd(). It will determine the additional commits from the commitID (last
// HEAD position), assign the patch tag to those commits and store them in
// project p.
func (m PatchManager) ApplyUpdate(p models.Project, patch, commitID string,
kv map[string]string,
) (models.Project, error) {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return p, revErr(err)
}
commitIDs, err := rc.History(commitID)
if err != nil {
return p, revErr(err)
}
if len(commitIDs) == 0 {
return p, fmt.Errorf("no commits found for patch %s", patch)
}
if models.Commits(p.Commits).HasTag(patch) {
log.Warnf("Patch name '%s' already exists", patch)
patch = makeUnique(patch)
log.Warnf("Creating new name: '%s'", patch)
}
for _, c := range commitIDs {
nc := models.NewCommit(rc, c, patch)
for msgid, subj := range kv {
if nc.Subject == "" {
continue
}
if strings.Contains(subj, nc.Subject) {
nc.MessageId = msgid
}
}
p.Commits = append(p.Commits, nc)
}
err = m.store().StoreProject(p, true)
return p, storeErr(err)
}
+94
View File
@@ -0,0 +1,94 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) DropPatch(patch string) error {
p, err := m.CurrentProject()
if err != nil {
return err
}
if !models.Commits(p.Commits).HasTag(patch) {
return fmt.Errorf("Patch '%s' not found in project '%s'", patch, p.Name)
}
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return revErr(err)
}
if !rc.Clean() {
return fmt.Errorf("Aborting... There are unstaged changes " +
"or a rebase in progress")
}
toRemove := make([]models.Commit, 0)
for _, c := range p.Commits {
if !rc.Exists(c.ID) {
log.Errorf("failed to find commit. %v", c)
return fmt.Errorf("Cannot drop patch. " +
"Please rebase first with ':patch rebase'")
}
if c.Tag == patch {
toRemove = append(toRemove, c)
}
}
removed := make(map[string]struct{})
for i := len(toRemove) - 1; i >= 0; i-- {
commitID := toRemove[i].ID
beforeIDs, err := rc.History(commitID)
if err != nil {
log.Errorf("failed to drop %v (commits before): %v", toRemove[i], err)
continue
}
err = rc.Drop(commitID)
if err != nil {
log.Errorf("failed to drop %v: %v", toRemove[i], err)
continue
}
removed[commitID] = struct{}{}
afterIDs, err := rc.History(p.Base.ID)
if err != nil {
log.Errorf("failed to drop %v (commits after): %v", toRemove[i], err)
continue
}
afterIDs = afterIDs[len(afterIDs)-len(beforeIDs):]
transform := make(map[string]string)
for j := 0; j < len(beforeIDs); j++ {
transform[beforeIDs[j]] = afterIDs[j]
}
for j, c := range p.Commits {
if newId, ok := transform[c.ID]; ok {
msgid := p.Commits[j].MessageId
p.Commits[j] = models.NewCommit(
rc,
newId,
p.Commits[j].Tag,
)
p.Commits[j].MessageId = msgid
}
}
}
if len(removed) < len(toRemove) {
return fmt.Errorf("Failed to drop commits. Dropped %d of %d.",
len(removed), len(toRemove))
}
commits := make([]models.Commit, 0, len(p.Commits))
for _, c := range p.Commits {
if _, ok := removed[c.ID]; ok {
continue
}
commits = append(commits, c)
}
p.Commits = commits
return storeErr(m.store().StoreProject(p, true))
}
+85
View File
@@ -0,0 +1,85 @@
package pama_test
import (
"reflect"
"testing"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func TestPatchmgmt_Drop(t *testing.T) {
setup := func(p models.Project) (pama.PatchManager, models.RevisionController, models.PersistentStorer) {
return newTestManager(
[]string{"0", "1", "2", "3", "4", "5"},
[]string{"0", "a", "b", "c", "d", "f"},
map[string]models.Project{p.Name: p}, p.Name,
)
}
tests := []struct {
name string
drop string
commits []models.Commit
want []models.Commit
}{
{
name: "drop only patch",
drop: "patch1",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
},
want: []models.Commit{},
},
{
name: "drop second one of two patch",
drop: "patch2",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
newCommit("2", "b", "patch2"),
},
want: []models.Commit{
newCommit("1", "a", "patch1"),
},
},
{
name: "drop first one of two patch",
drop: "patch1",
commits: []models.Commit{
newCommit("1", "a", "patch1"),
newCommit("2", "b", "patch2"),
},
want: []models.Commit{
newCommit("2_new", "b", "patch2"),
},
},
}
for _, test := range tests {
p := models.Project{
Name: "project1",
Commits: test.commits,
Base: newCommit("0", "0", ""),
}
mgr, rc, _ := setup(p)
err := mgr.DropPatch(test.drop)
if err != nil {
t.Errorf("test '%s' failed. %v", test.name, err)
}
q, _ := mgr.CurrentProject()
if !reflect.DeepEqual(q.Commits, test.want) {
t.Errorf("test '%s' failed. Commits don't match: "+
"got %v, but wanted %v", test.name, q.Commits,
test.want)
}
if len(test.want) > 0 {
last := test.want[len(test.want)-1]
if !rc.Exists(last.ID) {
t.Errorf("test '%s' failed. Could not find last commits: %v", test.name, last)
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) Find(hash string, p models.Project) (models.Commit, error) {
var c models.Commit
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return c, revErr(err)
}
if !rc.Exists(hash) {
return c, fmt.Errorf("no commit found for hash %s", hash)
}
return models.NewCommit(rc, hash, ""), nil
}
+34
View File
@@ -0,0 +1,34 @@
package pama
import (
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
// Init creates a new revision control project
func (m PatchManager) Init(name, path string, overwrite bool) error {
id, root, err := m.detect(path)
if err != nil {
return err
}
rc, err := m.rc(id, root)
if err != nil {
return err
}
headID, err := rc.Head()
if err != nil {
return err
}
p := models.Project{
Name: name,
Root: root,
RevctrlID: id,
Base: models.NewCommit(rc, headID, ""),
Commits: make([]models.Commit, 0),
}
store := m.store()
err = store.StoreProject(p, overwrite)
if err != nil {
return storeErr(err)
}
return storeErr(store.SetCurrent(name))
}
+59
View File
@@ -0,0 +1,59 @@
package pama
import (
"errors"
"io"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func (m PatchManager) Projects(name string) ([]models.Project, error) {
all, err := m.store().Projects()
if err != nil {
return nil, storeErr(err)
}
if len(name) == 0 {
return all, nil
}
var projects []models.Project
for _, p := range all {
if strings.Contains(p.Name, name) {
projects = append(projects, p)
}
}
if len(projects) == 0 {
return nil, errors.New("No projects found.")
}
return projects, nil
}
func (m PatchManager) NewReader(projects []models.Project) io.Reader {
cur, err := m.CurrentProject()
currentName := cur.Name
if err != nil {
log.Warnf("could not get current project: %v", err)
currentName = ""
}
readers := make([]io.Reader, 0, len(projects))
for _, p := range projects {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
log.Errorf("project '%s' failed with: %v", p.Name, err)
continue
}
notes := make(map[string]string)
for _, c := range p.Commits {
if !rc.Exists(c.ID) {
notes[c.ID] = "Rebase needed"
}
}
active := p.Name == currentName && len(projects) > 1
readers = append(readers, p.NewReader(active, notes))
}
return io.MultiReader(readers...)
}
+93
View File
@@ -0,0 +1,93 @@
package models
import (
"fmt"
"strings"
)
const (
Untracked = "untracked"
)
func NewCommit(r RevisionController, id, tag string) Commit {
return Commit{
ID: id,
Subject: r.Subject(id),
Author: r.Author(id),
Date: r.Date(id),
MessageId: "",
Tag: tag,
}
}
func (c Commit) Untracked() bool {
return c.Tag == Untracked
}
func (c Commit) Info() string {
s := []string{}
if c.Subject == "" {
s = append(s, "(no subject)")
} else {
s = append(s, c.Subject)
}
if c.Author != "" {
s = append(s, c.Author)
}
if c.Date != "" {
s = append(s, c.Date)
}
if c.MessageId != "" {
s = append(s, "<"+c.MessageId+">")
}
return strings.Join(s, ", ")
}
func (c Commit) String() string {
return fmt.Sprintf("%-6.6s %s", c.ID, c.Info())
}
type Commits []Commit
func (h Commits) Tags() []string {
var tags []string
dedup := make(map[string]struct{})
for _, c := range h {
_, ok := dedup[c.Tag]
if ok {
continue
}
tags = append(tags, c.Tag)
dedup[c.Tag] = struct{}{}
}
return tags
}
func (h Commits) HasTag(t string) bool {
for _, c := range h {
if c.Tag == t {
return true
}
}
return false
}
func (h Commits) Lookup(id string) (Commit, bool) {
for _, c := range h {
if c.ID == id {
return c, true
}
}
return Commit{}, false
}
type CommitIDs []string
func (c CommitIDs) Has(id string) bool {
for _, cid := range c {
if cid == id {
return true
}
}
return false
}
+106
View File
@@ -0,0 +1,106 @@
package models
// Commit represents a commit object in a revision control system.
type Commit struct {
// ID is the commit hash.
ID string
// Subject is the subject line of the commit.
Subject string
// Author is the author's name.
Author string
// Date associated with the given commit.
Date string
// MessageId is the message id for the message that contains the commit
// diff. This field is only set when commits were applied via patch
// apply system.
MessageId string
// Tag is a user label that is assigned to one or multiple commits. It
// creates a logical connection between a group of commits to represent
// a patch set.
Tag string
}
// WorktreeParent stores the name and repo location for the base project in the
// linked worktree project.
type WorktreeParent struct {
// Name is the project name from the base repo.
Name string
// Root is the root directory of the base repo.
Root string
}
// Project contains the data to access a revision control system and to store
// the internal patch tracking data.
type Project struct {
// Name is the project name and works as the project ID. Do not change
// it.
Name string
// Root represents the root directory of the revision control system.
Root string
// RevctrlID stores the ID for the revision control system.
RevctrlID string
// Worktree keeps the base repo information. If Worktree.Name and
// Worktree.Root are not zero, this project contains a linked worktree.
Worktree WorktreeParent
// Base represents the reference (base) commit.
Base Commit
// Commits contains the commits that are being tracked. The slice can
// contain any commit between the Base commit and HEAD. These commits
// will be updated by an applying, removing or rebase operation.
Commits []Commit
}
// RevisionController is an interface to a revision control system.
type RevisionController interface {
// Returns the commit hash of the HEAD commit.
Head() (string, error)
// History accepts a commit hash and returns a list of commit hashes
// between the provided hash and HEAD. The order of the returned slice
// is important. The commit hashes should be ordered from "earlier" to
// "later" where the last element must be HEAD.
History(string) ([]string, error)
// Clean returns true if there are no unstaged changes. If there are
// unstaged changes, applying and removing patches will not work.
Clean() bool
// Exists returns true if the commit hash exists in the commit history.
Exists(string) bool
// Subject returns the subject line for the provided commit hash.
Subject(string) string
// Author returns the author for the provided commit hash.
Author(string) string
// Date returns the date for the provided commit hash.
Date(string) string
// Drop removes the commit with the provided commit hash from the
// repository.
Drop(string) error
// ApplyCmd returns a string with an executable command that is used to
// apply patches with the :pipe command.
ApplyCmd() string
// CreateWorktree creates a worktree in path at commit.
CreateWorktree(path string, commit string) error
// DeleteWorktree removes the linked worktree stored in the path
// location. Note that this function should be called from the base
// repo.
DeleteWorktree(path string) error
}
// PersistentStorer is an interface to a persistent storage for Project structs.
type PersistentStorer interface {
// StoreProject saves the project data persistently. If overwrite is
// true, it will write over existing data.
StoreProject(Project, bool) error
// DeleteProject removes the project data from the store.
DeleteProject(string) error
// CurrentName returns the Project.Name for the active project.
CurrentName() (string, error)
// SetCurrent stores a Project.Name and make that project active.
SetCurrent(string) error
// Current returns the project data for the active project.
Current() (Project, error)
// Names returns a slice of Project.Name for all stored projects.
Names() ([]string, error)
// Project returns the stored project for the provided name.
Project(string) (Project, error)
// Projects returns all stored projects.
Projects() ([]Project, error)
}
+85
View File
@@ -0,0 +1,85 @@
package models
import (
"bytes"
"io"
"strings"
"text/template"
"git.sr.ht/~rjarry/aerc/lib/log"
)
var templateText = `
Project {{.Name}} {{if .IsActive}}[active]{{end}} {{if .IsWorktree}}[Linked worktree to {{.WorktreeParent}}]{{end}}
Directory {{.Root}}
Base {{with .Base.ID}}{{if ge (len .) 40}}{{printf "%-6.6s" .}}{{else}}{{.}}{{end}}{{end}}
{{$notes := .Notes}}{{$commits := .Commits}}
{{- range $index, $patch := .Patches}}
{{$patch}}:
{{- range (index $commits $patch)}}
{{with (index $notes .ID)}}[{{.}}] {{end}}{{. -}}
{{end}}
{{end -}}
`
var viewRenderer = template.Must(template.New("ProjectToText").Parse(templateText))
type view struct {
Name string
Root string
Base Commit
// Patches are the unique tag names.
Patches []string
// Commits is a map where the tag names are keys and the associated
// commits the values.
Commits map[string][]Commit
// Notes contain annotations of the commits where the commit hash is
// the key and the annotation is the value.
Notes map[string]string
// IsActive is true if the current project is selected.
IsActive bool
IsWorktree bool
WorktreeParent string
}
func newView(p Project, active bool, notes map[string]string) view {
v := view{
Name: p.Name,
Root: p.Root,
Base: p.Base,
Commits: make(map[string][]Commit),
Notes: notes,
IsActive: active,
IsWorktree: p.Worktree.Root != "" && p.Worktree.Name != "",
WorktreeParent: p.Worktree.Name,
}
for _, commit := range p.Commits {
patch := commit.Tag
commits, ok := v.Commits[patch]
if !ok {
v.Patches = append(v.Patches, patch)
}
commits = append(commits, commit)
v.Commits[patch] = commits
}
return v
}
func (v view) String() string {
var buf bytes.Buffer
err := viewRenderer.Execute(&buf, v)
if err != nil {
log.Errorf("failed to run template: %v", err)
}
return buf.String()
}
func (p Project) String() string {
return newView(p, false, nil).String()
}
func (p Project) NewReader(isActive bool, notes map[string]string) io.Reader {
return strings.NewReader(newView(p, isActive, notes).String())
}
+51
View File
@@ -0,0 +1,51 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/pama/revctrl"
"git.sr.ht/~rjarry/aerc/lib/pama/store"
)
type (
detectFn func(string) (string, string, error)
rcFn func(string, string) (models.RevisionController, error)
storeFn func() models.PersistentStorer
)
type PatchManager struct {
detect detectFn
rc rcFn
store storeFn
}
func New() PatchManager {
return PatchManager{
detect: revctrl.Detect,
rc: revctrl.New,
store: store.Store,
}
}
func FromFunc(d detectFn, r rcFn, s storeFn) PatchManager {
return PatchManager{
detect: d,
rc: r,
store: s,
}
}
func storeErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("store error: %w", err)
}
func revErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("revision control error: %w", err)
}
+178
View File
@@ -0,0 +1,178 @@
package pama_test
import (
"errors"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
var errNotFound = errors.New("not found")
func newCommit(id, subj, tag string) models.Commit {
return models.Commit{ID: id, Subject: subj, Tag: tag}
}
func newTestManager(
commits []string,
subjects []string,
data map[string]models.Project,
current string,
) (pama.PatchManager, models.RevisionController, models.PersistentStorer) {
rc := mockRevctrl{
commitIDs: commits,
titles: subjects,
}
store := mockStore{
data: data,
current: current,
}
return pama.FromFunc(
nil,
func(_ string, _ string) (models.RevisionController, error) {
return &rc, nil
},
func() models.PersistentStorer {
return &store
},
), &rc, &store
}
type mockRevctrl struct {
commitIDs []string
titles []string
}
func (c *mockRevctrl) Support() bool {
return true
}
func (c *mockRevctrl) Clean() bool {
return true
}
func (c *mockRevctrl) Root() (string, error) {
return "", nil
}
func (c *mockRevctrl) Head() (string, error) {
return c.commitIDs[len(c.commitIDs)-1], nil
}
func (c *mockRevctrl) History(commit string) ([]string, error) {
for i, s := range c.commitIDs {
if s == commit {
cp := make([]string, len(c.commitIDs[i+1:]))
copy(cp, c.commitIDs[i+1:])
return cp, nil
}
}
return nil, errNotFound
}
func (c *mockRevctrl) Exists(commit string) bool {
for _, s := range c.commitIDs {
if s == commit {
return true
}
}
return false
}
func (c *mockRevctrl) Subject(commit string) string {
for i, s := range c.commitIDs {
if s == commit {
return c.titles[i]
}
}
return ""
}
func (c *mockRevctrl) Author(commit string) string {
return ""
}
func (c *mockRevctrl) Date(commit string) string {
return ""
}
func (c *mockRevctrl) Drop(commit string) error {
for i, s := range c.commitIDs {
if s == commit {
c.commitIDs = append(c.commitIDs[:i], c.commitIDs[i+1:]...)
c.titles = append(c.titles[:i], c.titles[i+1:]...)
// modify commitIDs to simulate a "real" change in
// commit history that will also change all subsequent
// commitIDs
for j := i; j < len(c.commitIDs); j++ {
c.commitIDs[j] += "_new"
}
return nil
}
}
return errNotFound
}
func (c *mockRevctrl) CreateWorktree(_, _ string) error {
return nil
}
func (c *mockRevctrl) DeleteWorktree(_ string) error {
return nil
}
func (c *mockRevctrl) ApplyCmd() string {
return ""
}
type mockStore struct {
data map[string]models.Project
current string
}
func (s *mockStore) StoreProject(p models.Project, ow bool) error {
_, ok := s.data[p.Name]
if ok && !ow {
return errors.New("already there")
}
s.data[p.Name] = p
return nil
}
func (s *mockStore) DeleteProject(name string) error {
delete(s.data, name)
return nil
}
func (s *mockStore) CurrentName() (string, error) {
return s.current, nil
}
func (s *mockStore) SetCurrent(c string) error {
s.current = c
return nil
}
func (s *mockStore) Current() (models.Project, error) {
return s.data[s.current], nil
}
func (s *mockStore) Names() ([]string, error) {
var names []string
for name := range s.data {
names = append(names, name)
}
return names, nil
}
func (s *mockStore) Project(_ string) (models.Project, error) {
return models.Project{}, nil
}
func (s *mockStore) Projects() ([]models.Project, error) {
var ps []models.Project
for _, p := range s.data {
ps = append(ps, p)
}
return ps, nil
}
+81
View File
@@ -0,0 +1,81 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
// RebaseCommits fetches the commits between baseID and HEAD. The tags from the
// current project will be mapped onto the fetched commits based on either the
// commit hash or the commit subject.
func (m PatchManager) RebaseCommits(p models.Project, baseID string) ([]models.Commit, error) {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return nil, revErr(err)
}
if !rc.Exists(baseID) {
return nil, fmt.Errorf("cannot rebase on %s. "+
"commit does not exist", baseID)
}
commitIDs, err := rc.History(baseID)
if err != nil {
return nil, err
}
commits := make([]models.Commit, len(commitIDs))
for i := 0; i < len(commitIDs); i++ {
commits[i] = models.NewCommit(
rc,
commitIDs[i],
models.Untracked,
)
}
// map tags from the commits from the project p
for i, r := range commits {
for _, c := range p.Commits {
if c.ID == r.ID || c.Subject == r.Subject {
commits[i].MessageId = c.MessageId
commits[i].Tag = c.Tag
break
}
}
}
return commits, nil
}
// SaveRebased checks if the commits actually exist in the repo, repopulate the
// info fields and saves the baseID for project p.
func (m PatchManager) SaveRebased(p models.Project, baseID string, commits []models.Commit) error {
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return revErr(err)
}
exist := make([]models.Commit, 0, len(commits))
for _, c := range commits {
if !rc.Exists(c.ID) {
continue
}
exist = append(exist, c)
}
for i, c := range exist {
exist[i].Subject = rc.Subject(c.ID)
exist[i].Author = rc.Author(c.ID)
exist[i].Date = rc.Date(c.ID)
}
p.Commits = exist
if rc.Exists(baseID) {
p.Base = models.NewCommit(rc, baseID, "")
}
err = m.store().StoreProject(p, true)
return storeErr(err)
}
+128
View File
@@ -0,0 +1,128 @@
package revctrl
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func init() {
register("git", newGit)
}
func newGit(s string) models.RevisionController {
return &git{path: strings.TrimSpace(s)}
}
type git struct {
path string
}
func (g git) Support() bool {
_, exitcode, err := g.do("rev-parse")
return exitcode == 0 && err == nil
}
func (g git) Root() (string, error) {
s, _, err := g.do("rev-parse", "--show-toplevel")
return s, err
}
func (g git) Head() (string, error) {
s, _, err := g.do("rev-list", "-n 1", "HEAD")
return s, err
}
func (g git) History(commit string) ([]string, error) {
s, _, err := g.do("rev-list", "--reverse", fmt.Sprintf("%s..HEAD", commit))
return strings.Fields(s), err
}
func (g git) Subject(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%s", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Author(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%an", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Date(commit string) string {
s, exitcode, err := g.do("log", "-1", "--pretty=%as", commit)
if exitcode > 0 || err != nil {
return ""
}
return s
}
func (g git) Drop(commit string) error {
_, exitcode, err := g.do("rebase", "--onto", commit+"^", commit)
if exitcode > 0 {
return fmt.Errorf("failed to drop commit %s", commit)
}
return err
}
func (g git) Exists(commit string) bool {
_, exitcode, err := g.do("merge-base", "--is-ancestor", commit, "HEAD")
return exitcode == 0 && err == nil
}
func (g git) Clean() bool {
// is a rebase in progress?
dirs := []string{"rebase-merge", "rebase-apply"}
for _, dir := range dirs {
relPath, _, err := g.do("rev-parse", "--git-path", dir)
if err == nil {
if _, err := os.Stat(filepath.Join(g.path, relPath)); !os.IsNotExist(err) {
log.Errorf("%s exists: another rebase in progress..", dir)
return false
}
}
}
// are there unstaged changes?
s, exitcode, err := g.do("diff-index", "HEAD")
return len(s) == 0 && exitcode == 0 && err == nil
}
func (g git) CreateWorktree(target, commit string) error {
_, exitcode, err := g.do("worktree", "add", target, commit)
if exitcode > 0 {
return fmt.Errorf("failed to create worktree in %s: %w", target, err)
}
return err
}
func (g git) DeleteWorktree(target string) error {
_, exitcode, err := g.do("worktree", "remove", target)
if exitcode > 0 {
return fmt.Errorf("failed to delete worktree in %s: %w", target, err)
}
return err
}
func (g git) ApplyCmd() string {
// TODO: should we return a *exec.Cmd instead of a string?
return fmt.Sprintf("git -C %s am -3 --empty drop", g.path)
}
func (g git) do(args ...string) (string, int, error) {
proc := exec.Command("git", "-C", g.path)
proc.Args = append(proc.Args, args...)
proc.Env = os.Environ()
result, err := proc.Output()
return string(bytes.TrimSpace(result)), proc.ProcessState.ExitCode(), err
}
+48
View File
@@ -0,0 +1,48 @@
package revctrl
import (
"errors"
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
var ErrUnsupported = errors.New("unsupported")
type factoryFunc func(string) models.RevisionController
var controllers = map[string]factoryFunc{}
func register(controllerID string, fn factoryFunc) {
controllers[controllerID] = fn
}
func New(controllerID string, path string) (models.RevisionController, error) {
factoryFunc, ok := controllers[controllerID]
if !ok {
return nil, errors.New("cannot create revision control instance")
}
return factoryFunc(path), nil
}
type detector interface {
Support() bool
Root() (string, error)
}
func Detect(path string) (string, string, error) {
for controllerID, factoryFunc := range controllers {
rc, ok := factoryFunc(path).(detector)
if ok && rc.Support() {
log.Tracef("support found for %v", controllerID)
root, err := rc.Root()
if err != nil {
continue
}
log.Tracef("root found in %s", root)
return controllerID, root, nil
}
}
return "", "", fmt.Errorf("no supported repository found in %s", path)
}
+261
View File
@@ -0,0 +1,261 @@
package store
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/syndtr/goleveldb/leveldb"
)
const (
keyPrefix = "project."
)
var (
// versTag should be incremented when the underlying data structure
// changes.
versTag = []byte("0001")
versTagKey = []byte("version.tag")
currentKey = []byte("current.project")
)
func createKey(name string) []byte {
return []byte(keyPrefix + name)
}
func parseKey(key []byte) string {
return strings.TrimPrefix(string(key), keyPrefix)
}
func isProjectKey(key []byte) bool {
return bytes.HasPrefix(key, []byte(keyPrefix))
}
func cacheDir() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
dir = xdg.ExpandHome("~/.cache")
}
return path.Join(dir, "aerc"), nil
}
func openStorage() (*leveldb.DB, error) {
cd, err := cacheDir()
if err != nil {
return nil, fmt.Errorf("Unable to find project store directory: %w", err)
}
p := path.Join(cd, "projects")
db, err := leveldb.OpenFile(p, nil)
if err != nil {
return nil, fmt.Errorf("Unable to open project store: %w", err)
}
has, err := db.Has(versTagKey, nil)
if err != nil {
return nil, err
}
setTag := !has
if has {
vers, err := db.Get(versTagKey, nil)
if err != nil {
return nil, err
}
if !bytes.Equal(vers, versTag) {
log.Warnf("patch store: version mismatch: wipe data")
iter := db.NewIterator(nil, nil)
for iter.Next() {
err := db.Delete(iter.Key(), nil)
if err != nil {
log.Errorf("delete: %v")
}
}
iter.Release()
setTag = true
}
}
if setTag {
err := db.Put(versTagKey, versTag, nil)
if err != nil {
return nil, err
}
log.Infof("patch store: set version: %s", string(versTag))
}
return db, nil
}
func encode(p models.Project) ([]byte, error) {
return json.Marshal(p)
}
func decode(data []byte) (p models.Project, err error) {
err = json.Unmarshal(data, &p)
return
}
func Store() models.PersistentStorer {
return &instance{}
}
type instance struct{}
func (instance) StoreProject(p models.Project, overwrite bool) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(p.Name)
has, err := db.Has(key, nil)
if err != nil {
return err
}
if has && !overwrite {
return fmt.Errorf("Project '%s' already exists.", p.Name)
}
log.Debugf("project data: %v", p)
encoded, err := encode(p)
if err != nil {
return err
}
return db.Put(key, encoded, nil)
}
func (instance) DeleteProject(name string) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(name)
has, err := db.Has(key, nil)
if err != nil {
return err
}
if !has {
return fmt.Errorf("Project does not exist.")
}
return db.Delete(key, nil)
}
func (instance) CurrentName() (string, error) {
db, err := openStorage()
if err != nil {
return "", err
}
defer db.Close()
cur, err := db.Get(currentKey, nil)
if err != nil {
return "", err
}
return parseKey(cur), nil
}
func (instance) SetCurrent(name string) error {
db, err := openStorage()
if err != nil {
return err
}
defer db.Close()
key := createKey(name)
return db.Put(currentKey, key, nil)
}
func (instance) Current() (models.Project, error) {
db, err := openStorage()
if err != nil {
return models.Project{}, err
}
defer db.Close()
has, err := db.Has(currentKey, nil)
if err != nil {
return models.Project{}, err
}
if !has {
return models.Project{}, fmt.Errorf("No (current) project found; run 'project init' first.")
}
curProjectKey, err := db.Get(currentKey, nil)
if err != nil {
return models.Project{}, err
}
raw, err := db.Get(curProjectKey, nil)
if err != nil {
return models.Project{}, err
}
p, err := decode(raw)
if err != nil {
return models.Project{}, err
}
return p, nil
}
func (instance) Names() ([]string, error) {
db, err := openStorage()
if err != nil {
return nil, err
}
defer db.Close()
var names []string
iter := db.NewIterator(nil, nil)
for iter.Next() {
if !isProjectKey(iter.Key()) {
continue
}
names = append(names, parseKey(iter.Key()))
}
iter.Release()
return names, nil
}
func (instance) Project(name string) (models.Project, error) {
db, err := openStorage()
if err != nil {
return models.Project{}, err
}
defer db.Close()
raw, err := db.Get(createKey(name), nil)
if err != nil {
return models.Project{}, err
}
p, err := decode(raw)
if err != nil {
return models.Project{}, err
}
return p, nil
}
func (instance) Projects() ([]models.Project, error) {
var projects []models.Project
db, err := openStorage()
if err != nil {
return nil, err
}
defer db.Close()
iter := db.NewIterator(nil, nil)
for iter.Next() {
if !isProjectKey(iter.Key()) {
continue
}
p, err := decode(iter.Value())
if err != nil {
return nil, err
}
projects = append(projects, p)
}
iter.Release()
return projects, nil
}
+65
View File
@@ -0,0 +1,65 @@
package pama
import (
"fmt"
"regexp"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
)
func (m PatchManager) SwitchProject(name string) error {
c, err := m.CurrentProject()
if err == nil {
if c.Name == name {
return nil
}
}
names, err := m.store().Names()
if err != nil {
return storeErr(err)
}
found := false
for _, n := range names {
if n == name {
found = true
break
}
}
if !found {
return fmt.Errorf("Project '%s' not found", name)
}
return storeErr(m.store().SetCurrent(name))
}
var switchDebouncer *time.Timer
func DebouncedSwitchProject(name string) {
if switchDebouncer != nil {
if switchDebouncer.Stop() {
log.Debugf("pama: switch debounced")
}
}
if name == "" {
return
}
switchDebouncer = time.AfterFunc(500*time.Millisecond, func() {
if err := New().SwitchProject(name); err != nil {
log.Debugf("could not switch to project %s: %v",
name, err)
} else {
log.Debugf("project switch to project %s", name)
}
})
}
var fromSubject = regexp.MustCompile(
`\[\s*(RFC|DRAFT|[Dd]raft)*\s*(PATCH|[Pp]atch)\s+([^\s\]]+)\s*[vV]*[0-9/]*\s*\] `)
func FromSubject(s string) string {
matches := fromSubject.FindStringSubmatch(s)
if len(matches) >= 3 {
return matches[3]
}
return ""
}
+67
View File
@@ -0,0 +1,67 @@
package pama_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
func TestFromSubject(t *testing.T) {
tests := []struct {
s string
want string
}{
{
s: "[PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "[PATCH aerc v2] pama: new patch",
want: "aerc",
},
{
s: "[PATCH aerc 1/2] pama: new patch",
want: "aerc",
},
{
s: "[Patch aerc] pama: new patch",
want: "aerc",
},
{
s: "[patch aerc] pama: new patch",
want: "aerc",
},
{
s: "[RFC PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "[DRAFT PATCH aerc] pama: new patch",
want: "aerc",
},
{
s: "RE: [PATCH aerc v1] pama: new patch",
want: "aerc",
},
{
s: "[PATCH] pama: new patch",
want: "",
},
{
s: "just a subject line",
want: "",
},
{
s: "just a subject line with unrelated [asdf aerc v1]",
want: "",
},
}
for _, test := range tests {
got := pama.FromSubject(test.s)
if got != test.want {
t.Errorf("failed to get name from '%s': "+
"got '%s', want '%s'", test.s, got, test.want)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package pama
import (
"fmt"
"git.sr.ht/~rjarry/aerc/lib/log"
)
// Unlink removes provided project
func (m PatchManager) Unlink(name string) error {
store := m.store()
names, err := m.Names()
if err != nil {
return err
}
index := -1
for i, s := range names {
if s == name {
index = i
break
}
}
if index < 0 {
return fmt.Errorf("Project '%s' not found", name)
}
cur, err := store.CurrentName()
if err == nil && cur == name {
var next string
for _, s := range names {
if name != s {
next = s
break
}
}
err = store.SetCurrent(next)
if err != nil {
return storeErr(err)
}
}
p, err := store.Project(name)
if err == nil && isWorktree(p) {
err = m.deleteWorktree(p)
if err != nil {
log.Errorf("failed to delete worktree: %v", err)
}
err = store.SetCurrent(p.Worktree.Name)
if err != nil {
log.Errorf("failed to set current project: %v", err)
}
}
return storeErr(m.store().DeleteProject(name))
}
func (m PatchManager) Names() ([]string, error) {
names, err := m.store().Names()
return names, storeErr(err)
}
+88
View File
@@ -0,0 +1,88 @@
package pama
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
func cacheDir() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
dir = xdg.ExpandHome("~/.cache")
}
return path.Join(dir, "aerc"), nil
}
func makeWorktreeName(baseProject, tag string) string {
unique, err := generateTag(4)
if err != nil {
log.Infof("could not generate unique id: %v", err)
}
return strings.Join([]string{baseProject, "worktree", tag, unique}, "_")
}
func isWorktree(p models.Project) bool {
return p.Worktree.Name != "" && p.Worktree.Root != ""
}
func (m PatchManager) CreateWorktree(p models.Project, commitID, tag string,
) (models.Project, error) {
var w models.Project
if isWorktree(p) {
return w, fmt.Errorf("This is already a worktree.")
}
w.RevctrlID = p.RevctrlID
w.Base = models.Commit{ID: commitID}
w.Name = makeWorktreeName(p.Name, tag)
w.Worktree = models.WorktreeParent{Name: p.Name, Root: p.Root}
dir, err := cacheDir()
if err != nil {
return p, err
}
w.Root = filepath.Join(dir, "worktrees", w.Name)
rc, err := m.rc(p.RevctrlID, p.Root)
if err != nil {
return p, revErr(err)
}
err = rc.CreateWorktree(w.Root, w.Base.ID)
if err != nil {
return p, revErr(err)
}
err = m.store().StoreProject(w, true)
if err != nil {
return p, storeErr(err)
}
return w, nil
}
func (m PatchManager) deleteWorktree(p models.Project) error {
if !isWorktree(p) {
return nil
}
rc, err := m.rc(p.RevctrlID, p.Worktree.Root)
if err != nil {
return revErr(err)
}
err = rc.DeleteWorktree(p.Root)
if err != nil {
return revErr(err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package parse
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"regexp"
"git.sr.ht/~rjarry/aerc/lib/log"
)
var AnsiReg = regexp.MustCompile("\x1B\\[[0-?]*[ -/]*[@-~]")
// StripAnsi strips ansi escape codes from the reader
func StripAnsi(r io.Reader) io.Reader {
buf := bytes.NewBuffer(nil)
scanner := bufio.NewScanner(r)
scanner.Buffer(nil, 1024*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
line = AnsiReg.ReplaceAll(line, []byte(""))
_, err := buf.Write(line)
if err != nil {
log.Warnf("failed write ", err)
}
_, err = buf.Write([]byte("\n"))
if err != nil {
log.Warnf("failed write ", err)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "failed to read line: %v\n", err)
}
return buf
}
+471
View File
@@ -0,0 +1,471 @@
package parse
import (
"fmt"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
)
const dateFmt = "2006-01-02"
// ParseDateRange parses a date range into a start and end date. Dates are
// expected to be in the YYYY-MM-DD format.
//
// Start and end dates are connected by the range operator ".." where end date
// is not included in the date range.
//
// ParseDateRange can also parse open-ended ranges, i.e. start.. or ..end are
// allowed.
//
// Relative date terms (such as "1 week 1 day" or "1w 1d") can be used, too.
func DateRange(s string) (start, end time.Time, err error) {
s = cleanInput(s)
s = ensureRangeOp(s)
i := strings.Index(s, "..")
switch {
case i < 0:
// single date
start, err = translate(s)
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
end = start.AddDate(0, 0, 1)
case i == 0:
// end date only
if len(s) < 2 {
err = fmt.Errorf("no date found")
return
}
end, err = translate(s[2:])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
case i > 0:
// start date first
start, err = translate(s[:i])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
if len(s[i:]) <= 2 {
return
}
// and end dates if available
end, err = translate(s[(i + 2):])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
}
return
}
type dictFunc = func(bool) time.Time
// dict is a dictionary to translate words to dates. Map key must be at least 3
// characters for matching purposes.
var dict map[string]dictFunc = map[string]dictFunc{
"today": func(_ bool) time.Time {
return time.Now()
},
"yesterday": func(_ bool) time.Time {
return time.Now().AddDate(0, 0, -1)
},
"week": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Monday)+diff)
},
"month": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(0, diff, -t.Day()+1)
},
"year": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff, 0, -t.YearDay()+1)
},
"monday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Monday)+diff)
},
"tuesday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Tuesday)+diff)
},
"wednesday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Wednesday)+diff)
},
"thursday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Thursday)+diff)
},
"friday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Friday)+diff)
},
"saturday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Saturday)+diff)
},
"sunday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Sunday)+diff)
},
"january": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.January), -t.Day()+1)
},
"february": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.February), -t.Day()+1)
},
"march": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.March), -t.Day()+1)
},
"april": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.April), -t.Day()+1)
},
"may": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.May), -t.Day()+1)
},
"june": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.June), -t.Day()+1)
},
"july": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.July), -t.Day()+1)
},
"august": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.August), -t.Day()+1)
},
"september": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.September), -t.Day()+1)
},
"october": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.October), -t.Day()+1)
},
"november": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.November), -t.Day()+1)
},
"december": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.December), -t.Day()+1)
},
}
func daydiff(d time.Weekday) int {
daydiff := d - time.Now().Weekday()
if daydiff > 0 {
return int(daydiff) - 7
}
return int(daydiff)
}
func monthdiff(d time.Month) int {
monthdiff := d - time.Now().Month()
if monthdiff > 0 {
return int(monthdiff) - 12
}
return int(monthdiff)
}
// translate translates regular time words into date strings
func translate(s string) (time.Time, error) {
if s == "" {
return time.Now(), fmt.Errorf("empty string")
}
log.Tracef("input: %s", s)
s0 := s
// if next characters is integer, then parse a relative date
if '0' <= s[0] && s[0] <= '9' && hasUnit(s) {
relDate, err := RelativeDate(s)
if err != nil {
log.Errorf("could not parse relative date from '%s': %v",
s0, err)
} else {
log.Tracef("relative date: translated to %v from %s",
relDate, s0)
return bod(relDate.Apply(time.Now())), nil
}
}
// consult dictionary for terms translation
s, this, hasPrefix := handlePrefix(s)
for term, dateFn := range dict {
if term == "month" && !hasPrefix {
continue
}
if strings.Contains(term, s) {
log.Tracef("dictionary: translated to %s from %s",
term, s0)
return bod(dateFn(this)), nil
}
}
// this is a regular date, parse it in the normal format
log.Infof("parse: translates %s to regular format", s0)
return time.Parse(dateFmt, s)
}
// bod returns the begin of the day
func bod(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
func handlePrefix(s string) (string, bool, bool) {
var hasPrefix bool
this := true
if strings.HasPrefix(s, "this") {
hasPrefix = true
s = strings.TrimPrefix(s, "this")
}
if strings.HasPrefix(s, "last") {
hasPrefix = true
this = false
s = strings.TrimPrefix(s, "last")
}
return s, this, hasPrefix
}
func cleanInput(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "_", "")
return s
}
// RelDate is the relative date in the past, e.g. yesterday would be
// represented as RelDate{0,0,1}.
type RelDate struct {
Year uint
Month uint
Day uint
}
func (d RelDate) Apply(t time.Time) time.Time {
return t.AddDate(-int(d.Year), -int(d.Month), -int(d.Day))
}
// ParseRelativeDate parses a string of relative terms into a DateAdd.
//
// Syntax: N (year|month|week|day) ..
//
// The following are valid inputs:
// 5weeks1day
// 5w1d
//
// Adapted from the Go stdlib in src/time/format.go
func RelativeDate(s string) (RelDate, error) {
s0 := s
s = cleanInput(s)
var da RelDate
for s != "" {
var n uint
var err error
// expect an integer
if !('0' <= s[0] && s[0] <= '9') {
return da, fmt.Errorf("not a valid relative term: %s",
s0)
}
// consume integer
n, s, err = leadingInt(s)
if err != nil {
return da, fmt.Errorf("cannot read integer in %s",
s0)
}
// consume the units
i := 0
for ; i < len(s); i++ {
c := s[i]
if '0' <= c && c <= '9' {
break
}
}
if i == 0 {
return da, fmt.Errorf("missing unit in %s", s0)
}
u := s[:i]
s = s[i:]
switch u[0] {
case 'y':
da.Year += n
case 'm':
da.Month += n
case 'w':
da.Day += 7 * n
case 'd':
da.Day += n
default:
return da, fmt.Errorf("unknown unit %s in %s", u, s0)
}
}
return da, nil
}
func hasUnit(s string) (has bool) {
for _, u := range "ymwd" {
if strings.Contains(s, string(u)) {
return true
}
}
return false
}
// leadingInt parses and returns the leading integer in s.
//
// Adapted from the Go stdlib in src/time/format.go
func leadingInt(s string) (x uint, rem string, err error) {
i := 0
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
x = x*10 + uint(c) - '0'
}
return x, s[i:], nil
}
func ensureRangeOp(s string) string {
if strings.Contains(s, "..") {
return s
}
s0 := s
for _, m := range []string{"this", "last"} {
for _, u := range []string{"year", "month", "week"} {
term := m + u
if strings.Contains(s, term) {
if m == "last" {
return s0 + "..this" + u
} else {
return s0 + ".."
}
}
}
}
return s0
}
+97
View File
@@ -0,0 +1,97 @@
package parse_test
import (
"reflect"
"testing"
"time"
"git.sr.ht/~rjarry/aerc/lib/parse"
)
func TestParseDateRange(t *testing.T) {
dateFmt := "2006-01-02"
date := func(s string) time.Time { d, _ := time.Parse(dateFmt, s); return d }
tests := []struct {
s string
start time.Time
end time.Time
}{
{
s: "2022-11-01",
start: date("2022-11-01"),
end: date("2022-11-02"),
},
{
s: "2022-11-01..",
start: date("2022-11-01"),
},
{
s: "..2022-11-05",
end: date("2022-11-05"),
},
{
s: "2022-11-01..2022-11-05",
start: date("2022-11-01"),
end: date("2022-11-05"),
},
}
for _, test := range tests {
start, end, err := parse.DateRange(test.s)
if err != nil {
t.Errorf("ParseDateRange return error for %s: %v",
test.s, err)
}
if !start.Equal(test.start) {
t.Errorf("wrong start date; expected %v, got %v",
test.start, start)
}
if !end.Equal(test.end) {
t.Errorf("wrong end date; expected %v, got %v",
test.end, end)
}
}
}
func TestParseRelativeDate(t *testing.T) {
tests := []struct {
s string
want parse.RelDate
}{
{
s: "5 weeks 1 day",
want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
},
{
s: "5_weeks 1_day",
want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
},
{
s: "5weeks1day",
want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
},
{
s: "5w1d",
want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
},
{
s: "5y4m3w1d",
want: parse.RelDate{Year: 5, Month: 4, Day: 3*7 + 1},
},
}
for _, test := range tests {
da, err := parse.RelativeDate(test.s)
if err != nil {
t.Errorf("ParseRelativeDate return error for %s: %v",
test.s, err)
}
if !reflect.DeepEqual(da, test.want) {
t.Errorf("results don't match. expected %v, got %v",
test.want, da)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
package parse
import (
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/emersion/go-message/mail"
)
// MsgIDList parses a list of message identifiers. It returns message
// identifiers without angle brackets. If the header field is missing,
// it returns nil.
//
// This can be used on In-Reply-To and References header fields.
// If the field does not conform to RFC 5322, fall back
// to greedily parsing a subsequence of the original field.
func MsgIDList(h *mail.Header, key string) []string {
l, err := h.MsgIDList(key)
if err == nil {
return l
}
log.Errorf("%s: %s", err, h.Get(key))
// Expensive, fix your peer's MUA instead!
var list []string
header := &mail.Header{Header: h.Header.Copy()}
value := header.Get(key)
for err != nil && len(value) > 0 {
// Skip parsed IDs
if len(l) > 0 {
last := "<" + l[len(l)-1] + ">"
value = value[strings.Index(value, last)+len(last):]
list = append(list, l...)
}
// Skip a character until some IDs can be parsed
value = value[1:]
header.Set(key, value)
l, err = header.MsgIDList(key)
}
return append(list, l...)
}
+42
View File
@@ -0,0 +1,42 @@
package parse_test
import (
"testing"
"git.sr.ht/~rjarry/aerc/lib/parse"
"github.com/emersion/go-message/mail"
"github.com/stretchr/testify/assert"
)
func TestMsgIDList(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "valid",
input: "<1q@az> (cmt)\r\n <2w@sx> (khld)",
expected: []string{"1q@az", "2w@sx"},
},
{
name: "comma",
input: "<3e@dc>, <4r@fv>,\t<5t@gb>",
expected: []string{"3e@dc", "4r@fv", "5t@gb"},
},
{
name: "other non-CFWS separators",
input: "<6y@>, <hn@7u>\n <> <jm@8i>",
expected: []string{"hn@7u", "jm@8i"},
},
}
for _, test := range tests {
var h mail.Header
h.Set("References", test.input)
t.Run(test.name, func(t *testing.T) {
actual := parse.MsgIDList(&h, "References")
assert.Equal(t, test.expected, actual)
})
}
}
+129
View File
@@ -0,0 +1,129 @@
package parse
import (
"bytes"
"io"
"regexp"
"sort"
)
// Partial regexp to match the beginning of URLs and email addresses.
// The remainder of the matched URLs/emails is parsed manually.
var urlRe = regexp.MustCompile(
`([a-z]{2,8})://` + // URL start
`|` + // or
`(mailto:)?[[:alnum:]_+.~/-]*[[:alnum:]]@`, // email start
)
// HttpLinks searches a reader for a http link and returns a copy of the
// reader and a slice with links.
func HttpLinks(r io.Reader) (io.Reader, []string) {
buf, err := io.ReadAll(r)
if err != nil {
return r, nil
}
links := make(map[string]bool)
b := buf
match := urlRe.FindSubmatchIndex(b)
for ; match != nil; match = urlRe.FindSubmatchIndex(b) {
// Regular expressions do not really cut it here and we
// need to detect opening/closing braces to handle
// markdown link syntax.
var paren, bracket, ltgt, scheme int
var emitUrl bool
i, j := match[0], match[1]
b = b[i:]
scheme = j - i
j = scheme
// "inline" email without a mailto: prefix - add some extra checks for those
inlineEmail := len(match) > 4 && match[2] == -1 && match[4] == -1
for !emitUrl && j < len(b) && bytes.IndexByte(urichars, b[j]) != -1 {
switch b[j] {
case '[':
bracket++
j++
case '(':
paren++
j++
case '<':
ltgt++
j++
case ']':
bracket--
if bracket < 0 {
emitUrl = true
} else {
j++
}
case ')':
paren--
if paren < 0 {
emitUrl = true
} else {
j++
}
case '>':
ltgt--
if ltgt < 0 {
emitUrl = true
} else {
j++
}
case '&':
if inlineEmail {
emitUrl = true
} else {
j++
}
default:
j++
}
// we don't want those in inline emails
if inlineEmail && (paren > 0 || ltgt > 0 || bracket > 0) {
j--
emitUrl = true
}
}
// Heuristic to remove trailing characters that are
// valid URL characters, but typically not at the end of
// the URL
for trim := true; trim && j > 0; {
switch b[j-1] {
case '.', ',', ':', ';', '?', '!', '"', '\'', '%':
j--
default:
trim = false
}
}
if j == scheme {
// Only an URL scheme, ignore.
b = b[j:]
continue
}
url := string(b[:j])
if inlineEmail {
// Email address with missing mailto: scheme. Add it.
url = "mailto:" + url
}
links[url] = true
b = b[j:]
}
results := make([]string, 0, len(links))
for link := range links {
results = append(results, link)
}
sort.Strings(results)
return bytes.NewReader(buf), results
}
var urichars = []byte(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789-_.,~:;/?#@!$&%*+=\"'<>()[]",
)
+162
View File
@@ -0,0 +1,162 @@
package parse_test
import (
"io"
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/lib/parse"
)
func TestHyperlinks(t *testing.T) {
tests := []struct {
name string
text string
links []string
}{
{
name: "http-link",
text: "http://aerc-mail.org",
links: []string{"http://aerc-mail.org"},
},
{
name: "https-link",
text: "https://aerc-mail.org",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-in-text",
text: "text https://aerc-mail.org more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-in-parenthesis",
text: "text (https://aerc-mail.org) more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-in-quotes",
text: "text \"https://aerc-mail.org\" more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-in-angle-brackets",
text: "text <https://aerc-mail.org> more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-in-html",
text: "<a href=\"https://aerc-mail.org\">",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-twice",
text: "text https://aerc-mail.org more text https://aerc-mail.org more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "https-link-markdown",
text: "text [https://aerc-mail.org](https://aerc-mail.org) more text",
links: []string{"https://aerc-mail.org"},
},
{
name: "multiple-links",
text: "text https://aerc-mail.org more text http://git.sr.ht/~rjarry/aerc more text",
links: []string{"https://aerc-mail.org", "http://git.sr.ht/~rjarry/aerc"},
},
{
name: "rfc",
text: "text http://www.ietf.org/rfc/rfc2396.txt more text",
links: []string{"http://www.ietf.org/rfc/rfc2396.txt"},
},
{
name: "http-with-query-and-fragment",
text: "text <http://example.com:8042/over/there?name=ferret#nose> more text",
links: []string{"http://example.com:8042/over/there?name=ferret#nose"},
},
{
name: "http-with-at",
text: "text http://cnn.example.com&story=breaking_news@10.0.0.1/top_story.htm more text",
links: []string{"http://cnn.example.com&story=breaking_news@10.0.0.1/top_story.htm"},
},
{
name: "https-with-fragment",
text: "text https://www.ics.uci.edu/pub/ietf/uri/#Related more text",
links: []string{"https://www.ics.uci.edu/pub/ietf/uri/#Related"},
},
{
name: "https-with-query",
text: "text https://www.example.com/index.php?id_sezione=360&sid=3a5ebc944f41daa6f849f730f1 more text",
links: []string{"https://www.example.com/index.php?id_sezione=360&sid=3a5ebc944f41daa6f849f730f1"},
},
{
name: "https-onedrive",
text: "I have a link like this in an email (I deleted a few characters here-and-there for privacy) https://1drv.ms/w/s!Ap-KLfhNxS4fRt6tIvw?e=dW8WLO",
links: []string{"https://1drv.ms/w/s!Ap-KLfhNxS4fRt6tIvw?e=dW8WLO"},
},
{
name: "email",
text: "You can reach me via the somewhat strange, but nonetheless valid, email foo@baz.com",
links: []string{"mailto:foo@baz.com"},
},
{
name: "mailto",
text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:bar@fooz.fr. Thank you",
links: []string{"mailto:bar@fooz.fr"},
},
{
name: "mailto-ipv6",
text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:~mpldr/list@[2001:db8::7]",
links: []string{"mailto:~mpldr/list@[2001:db8::7]"},
},
{
name: "mailto-ipv6-query",
text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:~mpldr/list@[2001:db8::7]?subject=whazzup%3F",
links: []string{"mailto:~mpldr/list@[2001:db8::7]?subject=whazzup%3F"},
},
{
name: "simple email in <a href>",
text: `<a href="mailto:a@abc.com" rel="noopener noreferrer">`,
links: []string{"mailto:a@abc.com"},
},
{
name: "simple email in <a> body",
text: `<a href="#" rel="noopener noreferrer">a@abc.com</a><br/><p>more text</p>`,
links: []string{"mailto:a@abc.com"},
},
{
name: "emails in <a> href and body",
text: `<a href="mailto:a@abc.com" rel="noopener noreferrer">b@abc.com</a><br/><p>more text</p>`,
links: []string{"mailto:a@abc.com", "mailto:b@abc.com"},
},
{
name: "email in &lt;...&gt;",
text: `<div>01.02.2023, 10:11, "Firstname Lastname" &lt;a@abc.com&gt;:</div>`,
links: []string{"mailto:a@abc.com"},
},
}
for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
// make sure reader is exact copy of input reader
reader, parsedLinks := parse.HttpLinks(strings.NewReader(test.text))
if _, err := io.ReadAll(reader); err != nil {
t.Skipf("could not read text: %v", err)
}
// check correct parsed links
if len(parsedLinks) != len(test.links) {
t.Errorf("different number of links: got %d but expected %d", len(parsedLinks), len(test.links))
}
linkMap := make(map[string]struct{})
for _, got := range parsedLinks {
linkMap[got] = struct{}{}
}
for _, expected := range test.links {
if _, ok := linkMap[expected]; !ok {
t.Errorf("link[%d] not parsed: %s", i, expected)
}
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
package parse
import (
"regexp"
"sync"
"git.sr.ht/~rjarry/aerc/lib/log"
)
var reCache sync.Map
// Check if a string matches the specified regular expression.
// The regexp is compiled only once and stored in a cache for future use.
func MatchCache(s, expr string) bool {
var re interface{}
var found bool
if re, found = reCache.Load(expr); !found {
var err error
re, err = regexp.Compile(expr)
if err != nil {
log.Errorf("`%s` invalid regexp: %s", expr, err)
}
reCache.Store(expr, re)
}
if re, ok := re.(*regexp.Regexp); ok && re != nil {
return re.MatchString(s)
}
return false
}
+71
View File
@@ -0,0 +1,71 @@
package pinentry
import (
"fmt"
"os"
"os/exec"
"strings"
"sync/atomic"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
var pinentryMode int32 = 0
func Enable() {
if !config.General.UsePinentry {
return
}
if atomic.SwapInt32(&pinentryMode, 1) == 1 {
// cannot enter pinentry mode twice
return
}
ui.SuspendScreen()
}
func Disable() {
if atomic.SwapInt32(&pinentryMode, 0) == 0 {
// not in pinentry mode
return
}
ui.ResumeScreen()
}
func SetCmdEnv(cmd *exec.Cmd) {
if cmd == nil || atomic.LoadInt32(&pinentryMode) == 0 {
return
}
env := cmd.Env
if env == nil {
env = os.Environ()
}
hasTerm := false
hasGPGTTY := false
for _, e := range env {
switch {
case strings.HasPrefix(strings.ToUpper(e), "TERM="):
log.Debugf("pinentry: use %v", e)
hasTerm = true
case strings.HasPrefix(strings.ToUpper(e), "GPG_TTY="):
log.Debugf("pinentry: use %v", e)
hasGPGTTY = true
}
}
if !hasTerm {
env = append(env, "TERM=xterm-256color")
log.Debugf("pinentry: set TERM=xterm-256color")
}
if !hasGPGTTY {
tty := ttyname()
env = append(env, fmt.Sprintf("GPG_TTY=%s", tty))
log.Debugf("pinentry: set GPG_TTY=%s", tty)
}
cmd.Env = env
}
+45
View File
@@ -0,0 +1,45 @@
package pinentry
import (
"fmt"
"os"
"strings"
"git.sr.ht/~rjarry/aerc/lib/log"
)
var missingGPGTTYmsg = `
You need to set GPG_TTY manually before starting aerc. Add the following to your
.bashrc or whatever initialization file is used for shell invocations:
GPG_TTY=$(tty)
export GPG_TTY
Further information can be found here:
https://www.gnupg.org/documentation/manuals/gnupg/Invoking-GPG_002dAGENT.html
`
// ttyname returns current name of the pty. This is necessary in order to tell
// pinentry where to ask for the passphrase.
//
// If there is a GPG_TTY environment variable set, use this one. Otherwise, try
// readline() on /proc/<pid>/fd/0.
//
// If both approaches fail, the user's only option is to set GPG_TTY manually.
//
// If tty name could not be determined, an empty string is returned.
func ttyname() string {
if s := os.Getenv("GPG_TTY"); s != "" {
return s
}
// try readlink or else show missing GPG_TTY warning msg
tty, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/0", os.Getpid()))
if err != nil {
log.Debugf("readlink: '%s' with err: %v", tty, err)
log.Warnf(missingGPGTTYmsg)
return ""
}
return strings.TrimSpace(tty)
}
+466
View File
@@ -0,0 +1,466 @@
package rfc822
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"mime"
"regexp"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message"
_ "github.com/emersion/go-message/charset"
"github.com/emersion/go-message/mail"
)
type MultipartError struct {
e error
}
func (u MultipartError) Unwrap() error { return u.e }
func (u MultipartError) Error() string {
return "multipart error: " + u.e.Error()
}
// IsMultipartError returns a boolean indicating whether the error is known to
// report that the multipart message is malformed and could not be parsed.
func IsMultipartError(err error) bool {
return errors.As(err, new(MultipartError))
}
// RFC 1123Z regexp
var dateRe = regexp.MustCompile(`(((Mon|Tue|Wed|Thu|Fri|Sat|Sun))[,]?\s[0-9]{1,2})\s` +
`(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s` +
`([0-9]{4})\s([0-9]{2}):([0-9]{2})(:([0-9]{2}))?\s([\+|\-][0-9]{4})`)
func FetchEntityPartReader(e *message.Entity, index []int) (io.Reader, error) {
if len(index) == 0 {
// non multipart, simply return everything
return bufReader(e)
}
if mpr := e.MultipartReader(); mpr != nil {
idx := 0
for {
idx++
part, err := mpr.NextPart()
switch {
case message.IsUnknownCharset(err):
log.Warnf("FetchEntityPartReader: %v", err)
case message.IsUnknownEncoding(err):
log.Warnf("FetchEntityPartReader: %v", err)
case err != nil:
log.Warnf("FetchEntityPartReader: %v", err)
return bufReader(e)
}
if idx == index[0] {
rest := index[1:]
if len(rest) < 1 {
return bufReader(part)
}
return FetchEntityPartReader(part, index[1:])
}
}
}
return nil, fmt.Errorf("FetchEntityPartReader: unexpected code reached")
}
// TODO: the UI doesn't seem to like readers which aren't buffers
func bufReader(e *message.Entity) (io.Reader, error) {
var buf bytes.Buffer
if _, err := io.Copy(&buf, e.Body); err != nil {
return nil, err
}
return &buf, nil
}
// split a MIME type into its major and minor parts
func splitMIME(m string) (string, string) {
parts := strings.Split(m, "/")
if len(parts) != 2 {
return parts[0], ""
}
return parts[0], parts[1]
}
func fixContentType(h message.Header) (string, map[string]string) {
ct, rest := h.Get("Content-Type"), ""
if i := strings.Index(ct, ";"); i > 0 {
ct, rest = ct[:i], ct[i:]
}
// check if there are quotes around the content type
if strings.Contains(ct, "\"") {
header := strings.ReplaceAll(ct, "\"", "")
if rest != "" {
header += rest
}
h.Set("Content-Type", header)
if contenttype, params, err := h.ContentType(); err == nil {
return contenttype, params
}
}
// if all else fails, return text/plain
return "text/plain", nil
}
// ParseEntityStructure will parse the message and create a multipart structure
// for multipart messages. Parsing is done on a best-efforts basis:
//
// If the content-type cannot be parsed, ParseEntityStructure will try to fix
// it; otherwise, it returns a text/plain mime type as a fallback. No error will
// be returned.
//
// If a charset or encoding error is encountered for a message part of a
// multipart message, the error is logged and ignored. In those cases, we still
// get a valid message body but the content is just not decoded or converted. No
// error will be returned.
//
// If reading a multipart message fails, ParseEntityStructure will return a
// multipart error. This error indicates that this message is malformed and
// there is nothing more we can do. The caller is then advised to use a single
// text/plain body structure using CreateTextPlainPart().
func ParseEntityStructure(e *message.Entity) (*models.BodyStructure, error) {
var body models.BodyStructure
contentType, ctParams, err := e.Header.ContentType()
if err != nil {
// try to fix the error; if all measures fail, then return a
// text/plain content type to display at least plaintext
contentType, ctParams = fixContentType(e.Header)
}
mimeType, mimeSubType := splitMIME(contentType)
body.MIMEType = mimeType
body.MIMESubType = mimeSubType
body.Params = ctParams
body.Description = e.Header.Get("content-description")
body.Encoding = e.Header.Get("content-transfer-encoding")
if cd := e.Header.Get("content-disposition"); cd != "" {
contentDisposition, cdParams, err := e.Header.ContentDisposition()
if err != nil {
return nil, fmt.Errorf("could not parse content disposition: %w", err)
}
body.Disposition = contentDisposition
body.DispositionParams = cdParams
}
body.Parts = []*models.BodyStructure{}
if mpr := e.MultipartReader(); mpr != nil {
for {
part, err := mpr.NextPart()
switch {
case errors.Is(err, io.EOF):
return &body, nil
case message.IsUnknownCharset(err):
log.Warnf("ParseEntityStructure: %v", err)
case message.IsUnknownEncoding(err):
log.Warnf("ParseEntityStructure: %v", err)
case err != nil:
return nil, MultipartError{err}
}
ps, err := ParseEntityStructure(part)
if err != nil {
return nil, fmt.Errorf("could not parse child entity structure: %w", err)
}
body.Parts = append(body.Parts, ps)
}
}
return &body, nil
}
// CreateTextPlainBody creates a plain-vanilla text/plain body structure.
func CreateTextPlainBody() *models.BodyStructure {
body := &models.BodyStructure{}
body.MIMEType = "text"
body.MIMESubType = "plain"
body.Params = map[string]string{"charset": "utf-8"}
body.Parts = []*models.BodyStructure{}
return body
}
func parseEnvelope(h *mail.Header) *models.Envelope {
subj, err := h.Subject()
if err != nil {
log.Errorf("could not decode subject: %v", err)
subj = h.Get("Subject")
}
msgID, err := h.MessageID()
if err != nil {
log.Errorf("invalid Message-ID header: %v", err)
// proper parsing failed, so fall back to whatever is there
msgID = strings.Trim(h.Get("message-id"), "<>")
}
var irt string
irtList := parse.MsgIDList(h, "in-reply-to")
if len(irtList) > 0 {
irt = irtList[0]
}
date, err := parseDate(h)
if err != nil {
// if only the date parsing failed we still get the rest of the
// envelop structure in a valid state.
// Date parsing errors are fairly common and it's better to be
// slightly off than to not be able to read the mails at all
// hence we continue here
log.Errorf("invalid Date header: %v", err)
}
return &models.Envelope{
Date: date,
Subject: subj,
MessageId: msgID,
From: parseAddressList(h, "from"),
ReplyTo: parseAddressList(h, "reply-to"),
Sender: parseAddressList(h, "sender"),
To: parseAddressList(h, "to"),
Cc: parseAddressList(h, "cc"),
Bcc: parseAddressList(h, "bcc"),
InReplyTo: irt,
}
}
// If the date is formatted like ...... -0500 (EST), parser takes the EST part
// and ignores the numeric offset. Then it might easily fail to guess what EST
// means unless the proper locale is loaded. This function checks that, so such
// time values can be safely ignored
// https://stackoverflow.com/questions/49084316/why-doesnt-gos-time-parse-parse-the-timezone-identifier
func isDateOK(t time.Time) bool {
name, offset := t.Zone()
// non-zero offsets are fine
if offset != 0 {
return true
}
// zero offset is ok if that's UTC or GMT
if name == "UTC" || name == "GMT" || name == "" {
return true
}
// otherwise this date should not be trusted
return false
}
// parseDate tries to parse the date from the Date header with non std formats
// if this fails it tries to parse the received header as well
func parseDate(h *mail.Header) (time.Time, error) {
// here we store the best parsed time we have so far
// if we find no "correct" time, we'll use that
bestDate := time.Time{}
// trying the easy way
t, err := h.Date()
if err == nil {
if isDateOK(t) {
return t, nil
}
bestDate = t
}
text := h.Get("date")
// sometimes, no error occurs but the date is empty.
// In this case, guess time from received header field
if text == "" {
t, err := parseReceivedHeader(h)
if err == nil {
return t, nil
}
}
layouts := []string{
// X-Mailer: EarthLink Zoo Mail 1.0
"Mon, _2 Jan 2006 15:04:05 -0700 (GMT-07:00)",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, text); err == nil {
if isDateOK(t) {
return t, nil
}
bestDate = t
}
}
// still no success, try the received header
t, err = parseReceivedHeader(h)
if err == nil {
if isDateOK(t) {
return t, nil
}
bestDate = t
}
// do we have at least something?
if !bestDate.IsZero() {
return bestDate, nil
}
// sad...
return time.Time{}, fmt.Errorf("unrecognized date format: %s", text)
}
func parseReceivedHeader(h *mail.Header) (time.Time, error) {
guess, err := h.Text("received")
if err != nil {
return time.Time{}, fmt.Errorf("received header not parseable: %w",
err)
}
return time.Parse(time.RFC1123Z, dateRe.FindString(guess))
}
func parseAddressList(h *mail.Header, key string) []*mail.Address {
addrs, err := h.AddressList(key)
if len(addrs) == 0 {
// Only consider the error if the returned address list is empty
// Sometimes, we get a list of addresses and unknown charset
// errors which are not fatal.
if val := h.Get(key); val != "" {
if err != nil {
log.Errorf("%s: %s: %v", key, val, err)
}
// Header value is not empty but parsing completely
// failed. Return something so that the message can at
// least be displayed.
return []*mail.Address{{Name: val}}
}
return nil
}
for _, addr := range addrs {
// Handle invalid headers with quoted *AND* encoded names
if strings.HasPrefix(addr.Name, "=?") && strings.HasSuffix(addr.Name, "?=") {
d := mime.WordDecoder{CharsetReader: message.CharsetReader}
addr.Name, _ = d.DecodeHeader(addr.Name)
}
}
// If we got at least one address, ignore any returned error.
return addrs
}
// RawMessage is an interface that describes a raw message
type RawMessage interface {
NewReader() (io.ReadCloser, error)
ModelFlags() (models.Flags, error)
Labels() ([]string, error)
UID() models.UID
}
// MessageInfo populates a models.MessageInfo struct for the message.
// based on the reader returned by NewReader
func MessageInfo(raw RawMessage) (*models.MessageInfo, error) {
var parseErr error
r, err := raw.NewReader()
if err != nil {
return nil, err
}
defer r.Close()
msg, err := ReadMessage(r)
if err != nil {
return nil, fmt.Errorf("could not read message: %w", err)
}
bs, err := ParseEntityStructure(msg)
if IsMultipartError(err) {
log.Warnf("multipart error: %v", err)
bs = CreateTextPlainBody()
} else if err != nil {
return nil, fmt.Errorf("could not get structure: %w", err)
}
h := &mail.Header{Header: msg.Header}
env := parseEnvelope(h)
recDate, _ := parseReceivedHeader(h)
if recDate.IsZero() {
// better than nothing, if incorrect
recDate = env.Date
}
flags, err := raw.ModelFlags()
if err != nil {
return nil, err
}
labels, err := raw.Labels()
if err != nil {
return nil, err
}
return &models.MessageInfo{
BodyStructure: bs,
Envelope: env,
Flags: flags,
Labels: labels,
InternalDate: recDate,
RFC822Headers: h,
Size: 0,
Uid: raw.UID(),
Error: parseErr,
}, nil
}
// MessageHeaders populates a models.MessageInfo struct for the message.
// based on the reader returned by NewReader. Minimal information is included.
// There is no body structure or RFC822Headers set
func MessageHeaders(raw RawMessage) (*models.MessageInfo, error) {
var parseErr error
r, err := raw.NewReader()
if err != nil {
return nil, err
}
defer r.Close()
msg, err := ReadMessage(r)
if err != nil {
return nil, fmt.Errorf("could not read message: %w", err)
}
h := &mail.Header{Header: msg.Header}
env := parseEnvelope(h)
recDate, _ := parseReceivedHeader(h)
if recDate.IsZero() {
// better than nothing, if incorrect
recDate = env.Date
}
flags, err := raw.ModelFlags()
if err != nil {
return nil, err
}
labels, err := raw.Labels()
if err != nil {
return nil, err
}
return &models.MessageInfo{
Envelope: env,
Flags: flags,
Labels: labels,
InternalDate: recDate,
Refs: parse.MsgIDList(h, "references"),
Size: 0,
Uid: raw.UID(),
Error: parseErr,
}, nil
}
// NewCRLFReader returns a reader with CRLF line endings
func NewCRLFReader(r io.Reader) io.Reader {
var buf bytes.Buffer
scanner := bufio.NewScanner(r)
for scanner.Scan() {
buf.WriteString(scanner.Text() + "\r\n")
}
return &buf
}
// ReadMessage is a wrapper for the message.Read function to read a message
// from r. The message's encoding and charset are automatically decoded to
// UTF-8. If an unknown charset or unknown encoding is encountered, the error is
// logged but a nil error is returned since the entity object can still be read.
func ReadMessage(r io.Reader) (*message.Entity, error) {
entity, err := message.Read(r)
switch {
case message.IsUnknownCharset(err):
// message body is valid, just not converted, so continue
log.Warnf("ReadMessage: %v", err)
case message.IsUnknownEncoding(err):
// message body is valid, just not decoded, so continue
log.Warnf("ReadMessage: %v", err)
case err != nil:
return nil, fmt.Errorf("could not read message: %w", err)
}
return entity, nil
}
+190
View File
@@ -0,0 +1,190 @@
package rfc822
import (
"io"
"os"
"path/filepath"
"testing"
"time"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageInfoParser(t *testing.T) {
rootDir := "testdata/message/valid"
msgFiles, err := os.ReadDir(rootDir)
die(err)
for _, fi := range msgFiles {
if fi.IsDir() {
continue
}
p := fi.Name()
t.Run(p, func(t *testing.T) {
m := newMockRawMessageFromPath(filepath.Join(rootDir, p))
mi, err := MessageInfo(m)
if err != nil {
t.Fatal("Failed to create MessageInfo with:", err)
}
if perr := mi.Error; perr != nil {
t.Fatal("Expected no parsing error, but got:", mi.Error)
}
})
}
}
func TestMessageInfoMalformed(t *testing.T) {
rootDir := "testdata/message/malformed"
msgFiles, err := os.ReadDir(rootDir)
die(err)
for _, fi := range msgFiles {
if fi.IsDir() {
continue
}
p := fi.Name()
t.Run(p, func(t *testing.T) {
m := newMockRawMessageFromPath(filepath.Join(rootDir, p))
_, err := MessageInfo(m)
if err != nil {
t.Fatal(err)
}
})
}
}
func TestParseMessageDate(t *testing.T) {
// we use different times for "Date" and "Received" fields so we can check which one is parsed
// however, we accept both if the date header can be parsed using the current locale
tests := []struct {
date string
received string
utc []time.Time
}{
{
date: "Fri, 22 Dec 2023 11:19:01 +0000",
received: "from aaa.bbb.com for <user@host.com>; Fri, 22 Dec 2023 06:19:02 -0500 (EST)",
utc: []time.Time{
time.Date(2023, time.December, 22, 11, 19, 1, 0, time.UTC), // we expect the Date field to be parsed straight away
},
},
{
date: "Fri, 29 Dec 2023 14:06:37 +0100",
received: "from somewhere.com for a@b.c; Fri, 30 Dec 2023 4:06:43 +1300",
utc: []time.Time{
time.Date(2023, time.December, 29, 13, 6, 37, 0, time.UTC), // we expect the Date field to be parsed here
},
},
{
date: "Fri, 29 Dec 2023 00:51:00 EST",
received: "by hostname.com; Fri, 29 Dec 2023 00:51:33 -0500 (EST)",
utc: []time.Time{
time.Date(2023, time.December, 29, 5, 51, 33, 0, time.UTC), // in most cases the Received field will be parsed
time.Date(2023, time.December, 29, 5, 51, 0o0, 0, time.UTC), // however, if the EST locale is loaded, the Date header can be parsed too
},
},
}
for _, test := range tests {
h := mail.Header{}
h.SetText("Date", test.date)
h.SetText("Received", test.received)
res, err := parseDate(&h)
require.Nil(t, err)
found := false
for _, ref := range test.utc {
if ref.Equal(res.UTC()) {
found = true
break
}
}
require.True(t, found, "Can't properly parse date and time from the Date/Received headers")
}
}
func TestParseAddressList(t *testing.T) {
header := mail.HeaderFromMap(map[string][]string{
"From": {`"=?utf-8?B?U21pZXRhbnNraSwgV29qY2llY2ggVGFkZXVzeiBpbiBUZWFtcw==?=" <noreply@email.teams.microsoft.com>`},
"To": {`=?UTF-8?q?Oc=C3=A9ane_de_Seazon?= <hello@seazon.fr>`},
"Cc": {`=?utf-8?b?0KjQsNCz0L7QsiDQk9C10L7RgNCz0LjQuSB2aWEgZGlzY3Vzcw==?= <ovs-discuss@openvswitch.org>`},
"Bcc": {`"Foo, Baz Bar" <~foo/baz@bar.org>`},
"Reply-To": {`Someone`},
})
type vector struct {
kind string
header string
name string
email string
}
vectors := []vector{
{
kind: "quoted",
header: "Bcc",
name: "Foo, Baz Bar",
email: "~foo/baz@bar.org",
},
{
kind: "Qencoded",
header: "To",
name: "Océane de Seazon",
email: "hello@seazon.fr",
},
{
kind: "Bencoded",
header: "Cc",
name: "Шагов Георгий via discuss",
email: "ovs-discuss@openvswitch.org",
},
{
kind: "quoted+Bencoded",
header: "From",
name: "Smietanski, Wojciech Tadeusz in Teams",
email: "noreply@email.teams.microsoft.com",
},
{
kind: "no email",
header: "Reply-To",
name: "Someone",
email: "",
},
}
for _, vec := range vectors {
t.Run(vec.kind, func(t *testing.T) {
addrs := parseAddressList(&header, vec.header)
assert.Len(t, addrs, 1)
assert.Equal(t, vec.name, addrs[0].Name)
assert.Equal(t, vec.email, addrs[0].Address)
})
}
}
type mockRawMessage struct {
path string
}
func newMockRawMessageFromPath(p string) *mockRawMessage {
return &mockRawMessage{
path: p,
}
}
func (m *mockRawMessage) NewReader() (io.ReadCloser, error) {
return os.Open(m.path)
}
func (m *mockRawMessage) ModelFlags() (models.Flags, error) { return 0, nil }
func (m *mockRawMessage) Labels() ([]string, error) { return nil, nil }
func (m *mockRawMessage) UID() models.UID { return "" }
func die(err error) {
if err != nil {
panic(err)
}
}
+26
View File
@@ -0,0 +1,26 @@
Subject: Confirmation Needed gUdVJQBhsd
Content-Type: multipart/mixed; boundary="Nextpart_1Q2YJhd197991794467076Pgfa"
To: <BORK@example.com>
From: ""REGISTRAR"" <zdglopi-1Q2YJhd-noReply@example.com>
--Nextpart_1Q2YJhd197991794467076Pgfa
Content-Type: multipart/parallel; boundary="sg54sd54g54sdg54"
--sg54sd54g54sdg54
Content-Type: multipart/alternative; boundary="54qgf54q546f46qsf46qsf"
--54qgf54q546f46qsf46qsf
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: Hexa
--54qgf54q546f46qsf46qsf
Content-Type: text/html; charset=utf-8
<CeNteR><a hRef="https://example.com-ap-southeast-example.com.com/example.com#qs=r-acacaeehdiebadgdhgghcaegckhabababaggacihaccajfbacccgaehhbkacb"><b><h2>Congratulations Netflix Customer!</h2></b></a><br>
<HeaD>
<ObJECT>
--Nextpart_1Q2YJhd197991794467076Pgfa--
+45
View File
@@ -0,0 +1,45 @@
Subject: Your ECOLINES tickets
X-PHP-Originating-Script: 33:functions.inc.php
From: ECOLINES <ecolines@ecolines.lv>
Content-Type: multipart/mixed;
boundary="PHP-mixed-ba319678ca12656cfb8cd46e736ce09d"
Message-Id: <E1nvIQS-0004tm-Bc@legacy.ecolines.net>
Date: Sun, 29 May 2022 15:53:44 +0300
--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
Content-Type: multipart/alternative; boundary="PHP-alt-ba319678ca12656cfb8cd46e736ce09d"
--PHP-alt-ba319678ca12656cfb8cd46e736ce09d
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: 7bit
Your tickets are attached to this message. Also You can print out Your tickets from our website www.ecolines.net<b
r />
--PHP-alt-ba319678ca12656cfb8cd46e736ce09d
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: 7bit
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
--PHP-alt-ba319678ca12656cfb8cd46e736ce09d--
--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
Content-Type: "application/pdf"; name="17634428.pdf"
Content-Disposition: attachment; filename="17634428.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AFkAbwB1AHIAIAB0AGkAYwBrAGUAdCkKL0Ny
--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
Content-Type: "application/pdf"; name="invoice-6385490.pdf"
Content-Disposition: attachment; filename="invoice-6385490.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AEkAbgB2AG8AaQBjAGUpCi9DcmVhdG9yICj+
--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d--
+41
View File
@@ -0,0 +1,41 @@
package send
import (
"fmt"
"io"
"github.com/emersion/go-message/mail"
"git.sr.ht/~rjarry/aerc/worker/types"
)
func newJmapSender(
worker *types.Worker, from *mail.Address, rcpts []*mail.Address,
copyTo []string,
) (io.WriteCloser, error) {
var writer io.WriteCloser
done := make(chan error)
worker.PostAction(
&types.StartSendingMessage{From: from, Rcpts: rcpts, CopyTo: copyTo},
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
return
case *types.Unsupported:
done <- fmt.Errorf("unsupported by worker")
case *types.Error:
done <- msg.Error
case *types.MessageWriter:
writer = msg.Writer
default:
done <- fmt.Errorf("unexpected worker message: %#v", msg)
}
close(done)
},
)
err := <-done
return writer, err
}

Some files were not shown because too many files have changed in this diff Show More