mailpatch

package module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 2, 2026 License: MIT Imports: 13 Imported by: 0

README

go-mailpatch

Parse git format-patch emails in Go. Commit metadata, [PATCH n/m] series, unified diffs, and diffstat — zero dependencies.

Go Version Go Reference GitHub release (latest by date) CI

git format-patch turns commits into email: the subject becomes [PATCH 2/3] fix the thing, the author and date become headers, the commit message becomes the body, and the diff follows a --- separator and a diffstat. git send-email mails them; reviewers reply; maintainers run git am. go-mailpatch reads those messages back into structured Go — author, date, series position, commit message, and a fully parsed diff — without shelling out to git.

It grew out of matcha's developer-mail features (patch review and git-mail), pulled into a standalone, git-free, dependency-free library.

Features

  • Parse one message or a whole mbox. Parse for a single patch, ParseMbox for every message in an mbox, ParseSeries to group a thread into its cover letter + ordered patches.
  • Subject intelligence. [PATCH], [PATCH 2/3], [RFC PATCH v3 1/4] — the prefix is parsed into index, total, version, and cover-letter flag, and stripped from the clean subject. Non-patch subjects ([bug] …) are left untouched.
  • Real diff parsing. Per-file FileChange with change type (added/deleted/renamed/copied/modified), old/new paths, file modes, binary detection, and per-line hunks. Add/delete counts and a DiffStat come for free.
  • Standalone diff parser. ParseDiff works on a bare git diff with no email around it.
  • MIME-aware. Decodes quoted-printable / base64 bodies and RFC 2047 encoded-word headers; digs the text/plain part out of multipart mail.
  • Zero dependencies, never runs git. Standard library only. It parses; it does not apply. What you do with the result is up to you.

Install

go get github.com/floatpane/go-mailpatch

Requires Go 1.26+.

Usage

Parse a single patch email
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/floatpane/go-mailpatch"
)

func main() {
	p, err := mailpatch.Parse(os.Stdin)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s <%s>\n", p.AuthorName, p.AuthorEmail)
	fmt.Printf("Subject: %s\n", p.Subject)
	if p.Series.Total > 0 {
		fmt.Printf("Patch %d of %d (v%d)\n",
			p.Series.Index, p.Series.Total, p.Series.Version)
	}

	for _, f := range p.Files {
		fmt.Printf("  %-8s %s  +%d -%d\n",
			f.Type, f.Path(), f.Additions, f.Deletions)
	}
	fmt.Printf("%d files changed, +%d -%d\n",
		p.Stat.FilesChanged, p.Stat.Additions, p.Stat.Deletions)
}
$ git format-patch -1 --stdout | go run .
Ada Lovelace <ada@example.com>
Subject: parser: handle empty input
Patch 2 of 3 (v1)
  modified parser.go  +3 -0
1 files changed, +3 -0
Walk a patch series from an mbox
f, _ := os.Open("series.mbox")
defer f.Close()

s, err := mailpatch.ParseSeries(f)
if err != nil {
	log.Fatal(err)
}

if s.Cover != nil {
	fmt.Println("cover:", s.Cover.Subject)
}
fmt.Printf("v%d, %d/%d patches present (complete=%v)\n",
	s.Version, s.Len(), s.Total, s.Complete())

for _, p := range s.Patches { // already sorted by index
	fmt.Printf("  [%d/%d] %s\n", p.Series.Index, p.Series.Total, p.Subject)
}
Parse a bare diff (no email)
files, _ := mailpatch.ParseDiff(diffText)
for _, f := range files {
	for _, h := range f.Hunks {
		for _, ln := range h.Lines {
			switch ln.Kind {
			case mailpatch.Add:
				fmt.Println("+", ln.Text)
			case mailpatch.Delete:
				fmt.Println("-", ln.Text)
			}
		}
	}
}

What you get

Type Holds
Patch From / author name+email, Date, clean Subject, Message-ID / In-Reply-To / References, SeriesInfo, commit-message Body, raw Diff, parsed Files, DiffStat
SeriesInfo Index, Total, Version, Prefix, IsCover
FileChange OldPath / NewPath, Type, IsBinary, Old/NewMode, Additions / Deletions, Hunks
Hunk OldStart/OldLines, NewStart/NewLines, Section, Lines
Line Kind (Context / Add / Delete), Text
DiffStat FilesChanged, Additions, Deletions
Series Cover, ordered Patches, Version, Total

What this is not

  • Not a patch applier. It never runs git am and never touches your working tree. Parse here, apply (and validate) yourself.
  • Not a diff renderer. It gives you the structure; coloring/printing is the caller's job.
  • Not a full RFC 5322 MTA. It handles the headers and encodings that format-patch mail uses in practice, not every corner of email.

Documentation

Full API reference: pkg.go.dev/github.com/floatpane/go-mailpatch

Guides and diagrams: see docs/.

Sister projects

Project Role
floatpane/matcha Reference consumer — uses this library for patch review and git-mail.
floatpane/go-secretbox Sibling extraction — password-based encryption for data at rest.

Contributing

PRs welcome. See CONTRIBUTING.md.

Security

It parses untrusted list mail. Report vulnerabilities privately via SECURITY.md.

License

MIT. See LICENSE.

Documentation

Overview

Package mailpatch parses git "format-patch" emails into structured data.

`git format-patch` turns commits into RFC 5322 email messages: the commit subject becomes the mail Subject (prefixed with "[PATCH n/m]"), the author and date become headers, the commit message becomes the body, and the diff follows after a "---" separator and a diffstat. `git send-email` mails those out; reviewers reply, and maintainers feed them back to `git am`.

mailpatch reads one of those messages — or a whole mbox of them — and gives you the pieces without shelling out to git:

  • Parse / ParseBytes decode a single message into a Patch: author, date, cleaned subject, [PATCH n/m] series position, commit message body, and the parsed diff (per-file hunks plus a diffstat).
  • ParseMbox splits an mbox into one Patch per message.
  • ParseSeries groups an mbox into a Series: the cover letter (the "0/n" message) plus the numbered patches in order.
  • ParseDiff parses a bare unified diff on its own, no email envelope.

It depends only on the standard library and never executes git.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrEmpty is returned when the input has no message at all.
	ErrEmpty = errors.New("mailpatch: empty input")
	// ErrMalformed is returned when the input is not a parseable RFC 5322
	// message (bad headers, truncated mid-header, and similar).
	ErrMalformed = errors.New("mailpatch: malformed message")
)

Sentinel errors. Compare with errors.Is.

Functions

This section is empty.

Types

type ChangeType

type ChangeType int

ChangeType classifies what happened to a file in a diff.

const (
	// Modified is an in-place edit (the default).
	Modified ChangeType = iota
	// Added is a new file (old side is /dev/null).
	Added
	// Deleted is a removed file (new side is /dev/null).
	Deleted
	// Renamed is a move, possibly with edits.
	Renamed
	// Copied is a copy, possibly with edits.
	Copied
)

func (ChangeType) String

func (c ChangeType) String() string

type DiffStat

type DiffStat struct {
	FilesChanged int
	Additions    int
	Deletions    int
}

DiffStat is the summary count across a set of file changes.

type FileChange

type FileChange struct {
	OldPath  string
	NewPath  string
	Type     ChangeType
	IsBinary bool
	// OldMode and NewMode are the unix mode strings when git reports them
	// (e.g. "100644"), otherwise empty.
	OldMode string
	NewMode string
	// Additions and Deletions count added and removed lines across all hunks.
	Additions int
	Deletions int
	Hunks     []Hunk
}

FileChange is the diff for a single file.

func ParseDiff

func ParseDiff(diff string) ([]FileChange, error)

ParseDiff parses a unified diff (git or plain) into per-file changes. It accepts the output of `git diff`/`git format-patch` as well as a bare "--- / +++ / @@" diff with no "diff --git" headers. Unrecognized lines are ignored, so a diff embedded in surrounding text still parses.

func (FileChange) Path

func (f FileChange) Path() string

Path returns the file's current path: NewPath, or OldPath for a deletion.

type Hunk

type Hunk struct {
	OldStart int
	OldLines int
	NewStart int
	NewLines int
	// Section is the text after the closing "@@" (often the enclosing
	// function), trimmed.
	Section string
	Lines   []Line
}

Hunk is one "@@ ... @@" section of a file diff.

type Line

type Line struct {
	Kind LineKind
	Text string
}

Line is one line within a hunk, with its leading +/-/space removed.

type LineKind

type LineKind int

LineKind tags a diff line as context, addition, or deletion.

const (
	// Context is an unchanged line (leading space).
	Context LineKind = iota
	// Add is an added line (leading '+').
	Add
	// Delete is a removed line (leading '-').
	Delete
)

type Patch

type Patch struct {
	// From is the raw From header (decoded from any RFC 2047 encoding).
	From string
	// AuthorName and AuthorEmail are From split into its parts, best effort.
	AuthorName  string
	AuthorEmail string
	// Date is the parsed Date header; the zero time if it was absent or
	// unparseable.
	Date time.Time

	// Subject is the subject with any "[PATCH ...]" prefix stripped.
	Subject string
	// RawSubject is the original, undecoded-prefix subject line.
	RawSubject string

	// MessageID, InReplyTo and References come from the corresponding headers
	// (angle brackets stripped). They thread a series together.
	MessageID  string
	InReplyTo  string
	References []string

	// Series is the position parsed from the subject prefix.
	Series SeriesInfo

	// Body is the commit message: everything between the headers and the
	// diffstat/diff separator.
	Body string

	// Diff is the raw unified diff text, signature stripped. Empty for a
	// cover letter.
	Diff string
	// Files is Diff parsed into per-file changes.
	Files []FileChange
	// Stat is the diffstat computed from Files.
	Stat DiffStat

	// Header is the full set of decoded message headers, for callers that
	// need a field this struct does not surface.
	Header mail.Header
}

Patch is a single parsed format-patch email.

A message that carries no diff — most often a "0/n" cover letter — still parses into a Patch; its Diff is empty and HasDiff reports false.

func Parse

func Parse(r io.Reader) (*Patch, error)

Parse decodes a single format-patch email from r.

func ParseBytes

func ParseBytes(b []byte) (*Patch, error)

ParseBytes is Parse over an in-memory message.

func ParseMbox

func ParseMbox(r io.Reader) ([]*Patch, error)

ParseMbox parses every message in an mbox stream into a Patch, in file order. Messages without a diff (cover letters) are included.

func (*Patch) HasDiff

func (p *Patch) HasDiff() bool

HasDiff reports whether the message carried an actual diff.

func (*Patch) IsCoverLetter

func (p *Patch) IsCoverLetter() bool

IsCoverLetter reports whether this is a series cover letter: a "0/n" subject prefix, or simply a patch mail with no diff.

type Series

type Series struct {
	// Cover is the "0/n" cover letter, or nil if the series had none.
	Cover *Patch
	// Patches are the numbered patches, sorted by SeriesInfo.Index.
	Patches []*Patch
	// Version is the series revision (1, 2, ... from "[PATCH vN ...]").
	Version int
	// Total is the expected patch count (m in "[PATCH n/m]"), 0 if unknown.
	Total int
}

Series is a patch series: an optional cover letter plus the numbered patches, ordered by their position in the series.

func ParseSeries

func ParseSeries(r io.Reader) (*Series, error)

ParseSeries parses an mbox and groups it into a single Series: the cover letter (if any) and the numbered patches sorted by index.

func (*Series) Complete

func (s *Series) Complete() bool

Complete reports whether every patch in the series is present: Total is known and that many numbered patches were parsed.

func (*Series) Len

func (s *Series) Len() int

Len returns the number of numbered patches in the series.

type SeriesInfo

type SeriesInfo struct {
	// Index is n in "[PATCH n/m]"; 0 for a cover letter or a lone patch with
	// no "n/m".
	Index int
	// Total is m in "[PATCH n/m]"; 0 when the subject had no count.
	Total int
	// Version is the revision: 2 for "[PATCH v2 ...]", 1 when unspecified.
	Version int
	// Prefix is the prefix words other than the version and count, e.g.
	// "PATCH" or "RFC PATCH".
	Prefix string
	// IsCover is true for the "0/m" message.
	IsCover bool
}

SeriesInfo is the position of a patch within a series, parsed from the "[PATCH n/m]" (or "[RFC PATCH v2 n/m]") subject prefix.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL