init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!
|
||||
`)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package hooks
|
||||
|
||||
type HookType interface {
|
||||
Cmd() string
|
||||
Env() []string
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package ipc
|
||||
|
||||
type Handler interface {
|
||||
Command(args []string) error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !notmuch
|
||||
// +build !notmuch
|
||||
|
||||
package lib
|
||||
|
||||
func NotmuchVersion() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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-_.,~:;/?#@!$&%*+=\"'<>()[]",
|
||||
)
|
||||
@@ -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 <...>",
|
||||
text: `<div>01.02.2023, 10:11, "Firstname Lastname" <a@abc.com>:</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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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--
|
||||
@@ -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--
|
||||
@@ -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
Reference in New Issue
Block a user