Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ The Screener is where first-time senders wait. `hey screener list` returns clear

`hey bulk-reply preview` is read-only and resolves each posting to its latest replyable entry. `hey bulk-reply send` resolves the selection again, skips threads without a replyable entry, keeps HEY's server-provided name tag, and returns the exact reply count, delivery ID, delayed state, undo URL, and undo command. Posting IDs must be positive and unique. The message can come from `-m`, stdin, or `$EDITOR`; `--attach` is repeatable.

`--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachment list <thread-id>` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set.
`--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachment list <thread-id>` returns every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set.

Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page <next_page>` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page <next_page>` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs.

Expand Down
29 changes: 28 additions & 1 deletion internal/attachments/save.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
Expand All @@ -19,6 +20,25 @@ type Downloader interface {
DownloadBlob(context.Context, string, io.Writer) (int64, http.Header, error)
}

const heyBlobPathPrefix = "/rails/active_storage/blobs/"

// IsHEYBlobURL reports whether source is a clean relative path to HEY's blob storage.
func IsHEYBlobURL(source string) bool {
parsed, err := url.Parse(source)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.User != nil || parsed.Opaque != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return false
}
if parsed.Path == "" || strings.Contains(parsed.Path, `\`) || path.Clean(parsed.Path) != parsed.Path || !strings.HasPrefix(parsed.Path, heyBlobPathPrefix) {
return false
}
for _, character := range parsed.Path {
if unicode.IsControl(character) {
return false
}
}
return len(parsed.Path) > len(heyBlobPathPrefix)
}

func Destination(outputPath, filename string) (string, error) {
if outputPath == "" {
return PortableFilename(filename)
Expand Down Expand Up @@ -61,7 +81,7 @@ func PortableFilename(filename string) (string, error) {

// SaveBytes safely writes data to destination. Existing paths are preserved unless force is set.
func SaveBytes(destination string, data []byte, force bool) (int64, error) {
return Save(context.Background(), byteDownloader(data), destination, "", force)
return save(context.Background(), byteDownloader(data), destination, "", force)
}

type byteDownloader []byte
Expand All @@ -72,6 +92,13 @@ func (data byteDownloader) DownloadBlob(_ context.Context, _ string, writer io.W
}

func Save(ctx context.Context, downloader Downloader, destination, sourceURL string, force bool) (int64, error) {
if !IsHEYBlobURL(sourceURL) {
return 0, apierr.ErrAPI(0, "attachment URL does not identify a HEY blob")
}
return save(ctx, downloader, destination, sourceURL, force)
}

func save(ctx context.Context, downloader Downloader, destination, sourceURL string, force bool) (int64, error) {
if !force {
if _, err := os.Lstat(destination); err == nil {
return 0, apierr.ErrUsage(fmt.Sprintf("destination already exists: %s (use --force to replace it)", destination))
Expand Down
52 changes: 48 additions & 4 deletions internal/attachments/save_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,64 @@ func TestSaveBytesWritesContentSafely(t *testing.T) {
assertFileContent(t, destination, "Start,End\n09:00,10:00\n")
}

func TestIsHEYBlobURL(t *testing.T) {
for _, test := range []struct {
source string
want bool
}{
{source: "/rails/active_storage/blobs/redirect/signed/report.pdf", want: true},
{source: "/rails/active_storage/blobs/proxy/signed/report%20copy.pdf", want: true},
{source: "/identity.json"},
{source: "https://app.hey.com/rails/active_storage/blobs/redirect/signed/report.pdf"},
{source: "//app.hey.com/rails/active_storage/blobs/redirect/signed/report.pdf"},
{source: "/rails/active_storage/blobs/../identity.json"},
{source: "/rails/active_storage/blobs/%2e%2e/identity.json"},
{source: `/rails/active_storage/blobs/redirect/signed/..\identity.json`},
{source: "/rails/active_storage/blobs/redirect/signed/report.pdf?download=1"},
{source: "/rails/active_storage/blobs/redirect/signed/report.pdf#fragment"},
{source: "/rails/active_storage/blobs/"},
} {
if got := IsHEYBlobURL(test.source); got != test.want {
t.Errorf("IsHEYBlobURL(%q) = %t, want %t", test.source, got, test.want)
}
}
}

func TestSaveRejectsNonBlobURLBeforeDownload(t *testing.T) {
called := false
downloader := downloadFunc(func(_ context.Context, _ string, _ io.Writer) (int64, http.Header, error) {
called = true
return 0, nil, nil
})
destination := filepath.Join(t.TempDir(), "invoice.pdf")

_, err := Save(context.Background(), downloader, destination, "/identity.json", true)
var saveErr *apierr.Error
if !errors.As(err, &saveErr) || saveErr.Code != apierr.CodeAPI {
t.Fatalf("Save error = %v", err)
}
if called {
t.Error("Save requested a non-blob URL")
}
if _, err := os.Stat(destination); !os.IsNotExist(err) {
t.Errorf("Save created a destination for a non-blob URL: %v", err)
}
}

func TestSavePreservesExistingFileUnlessForced(t *testing.T) {
destination := filepath.Join(t.TempDir(), "quarterly-report.pdf")
if err := os.WriteFile(destination, []byte("keep me"), 0o600); err != nil {
t.Fatal(err)
}

_, err := Save(context.Background(), writeDownload("new report"), destination, "/report.pdf", false)
_, err := Save(context.Background(), writeDownload("new report"), destination, "/rails/active_storage/blobs/redirect/signed/report.pdf", false)
var saveErr *apierr.Error
if !errors.As(err, &saveErr) || saveErr.Code != "usage" || !strings.Contains(saveErr.Message, "use --force") {
t.Fatalf("existing destination error = %v", err)
}
assertFileContent(t, destination, "keep me")

written, err := Save(context.Background(), writeDownload("new report"), destination, "/report.pdf", true)
written, err := Save(context.Background(), writeDownload("new report"), destination, "/rails/active_storage/blobs/redirect/signed/report.pdf", true)
if err != nil {
t.Fatal(err)
}
Expand All @@ -109,7 +153,7 @@ func TestSaveDoesNotReplaceFileCreatedDuringDownload(t *testing.T) {
return int64(written), nil, nil
}

_, err := Save(context.Background(), downloadFunc(downloader), destination, "/report.pdf", false)
_, err := Save(context.Background(), downloadFunc(downloader), destination, "/rails/active_storage/blobs/redirect/signed/report.pdf", false)
var saveErr *apierr.Error
if !errors.As(err, &saveErr) || saveErr.Code != "usage" {
t.Fatalf("concurrent destination error = %v", err)
Expand All @@ -130,7 +174,7 @@ func TestSaveRemovesPartialFileAndPreservesDownloadError(t *testing.T) {
return int64(written), nil, downloadErr
}

written, err := Save(context.Background(), downloadFunc(downloader), destination, "/report.pdf", false)
written, err := Save(context.Background(), downloadFunc(downloader), destination, "/rails/active_storage/blobs/redirect/signed/report.pdf", false)
if !errors.Is(err, downloadErr) {
t.Fatalf("download error = %v, want %v", err, downloadErr)
}
Expand Down
73 changes: 71 additions & 2 deletions internal/cmd/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package cmd

import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -43,6 +47,7 @@ func newAttachmentsCommand() *attachmentsCommand {
attachmentsCommand.cmd = &cobra.Command{
Use: "list <thread-id>",
Short: "List a thread's attachments",
Long: "List every named downloadable file in a thread, including named inline images. Each returned ID identifies the same file when passed to attachment save.",
Example: ` hey attachment list 12345
hey attachment list 12345 --json
hey attachment list 12345 --allow-partial`,
Expand Down Expand Up @@ -145,9 +150,11 @@ func attachmentsInThread(ctx context.Context, threadID int64) ([]threadAttachmen
if loaded.Message == nil {
continue
}
for attachmentIndex, attachment := range htmlutil.ExtractAttachments(loaded.Message.Content) {
messageAttachments := htmlutil.ExtractAttachments(loaded.Message.Content)
ids := attachmentIDs(loaded.Entry.Id, messageAttachments)
for index, attachment := range messageAttachments {
attachments = append(attachments, threadAttachment{
ID: attachmentID(loaded.Entry.Id, attachmentIndex+1),
ID: ids[index],
MessageID: loaded.Entry.Id,
Filename: attachment.Filename,
ContentType: attachment.ContentType,
Expand All @@ -163,6 +170,68 @@ func attachmentID(messageID int64, position int) string {
return fmt.Sprintf("%d:%d", messageID, position)
}

func attachmentIDs(messageID int64, attachments []htmlutil.Attachment) []string {
ids := make([]string, len(attachments))
directPosition := 0
embeddedOccurrences := make(map[string]int)
for index, attachment := range attachments {
if !attachment.Embedded {
directPosition++
ids[index] = attachmentID(messageID, directPosition)
continue
}

key := embeddedAttachmentKey(attachment)
embeddedOccurrences[key]++
ids[index] = fmt.Sprintf("%d:e-%s", messageID, key)
if embeddedOccurrences[key] > 1 {
ids[index] += fmt.Sprintf(".%d", embeddedOccurrences[key])
}
}
return ids
}

func embeddedAttachmentKey(attachment htmlutil.Attachment) string {
identity := "sgid\x00" + attachment.SGID
if attachment.SGID == "" {
byteSize := ""
if attachment.ByteSize != nil {
byteSize = strconv.FormatInt(*attachment.ByteSize, 10)
}
identity = strings.Join([]string{"file", attachment.URL, attachment.Filename, attachment.ContentType, byteSize}, "\x00")
}
digest := sha256.Sum256([]byte(identity))
return base64.RawURLEncoding.EncodeToString(digest[:])
}

func validAttachmentSelector(selector string) bool {
if position, err := strconv.Atoi(selector); err == nil {
return position > 0
}
if !strings.HasPrefix(selector, "e-") {
return false
}
key, occurrence, hasOccurrence := strings.Cut(strings.TrimPrefix(selector, "e-"), ".")
digest, err := base64.RawURLEncoding.DecodeString(key)
if err != nil || len(digest) != sha256.Size || base64.RawURLEncoding.EncodeToString(digest) != key {
return false
}
if !hasOccurrence {
return true
}
position, err := strconv.Atoi(occurrence)
return err == nil && position > 1 && strconv.Itoa(position) == occurrence
}

func findAttachmentByID(messageID int64, id string, attachments []htmlutil.Attachment) (htmlutil.Attachment, bool) {
for index, candidateID := range attachmentIDs(messageID, attachments) {
if candidateID == id {
return attachments[index], true
}
}
return htmlutil.Attachment{}, false
}

func formatOptionalByteSize(size *int64) string {
if size == nil {
return "—"
Expand Down
20 changes: 11 additions & 9 deletions internal/cmd/attachments_save.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func (c *attachmentsSaveCommand) run(cmd *cobra.Command, args []string) error {
return err
}

messageID, position, err := parseAttachmentID(args[0])
messageID, selector, err := parseAttachmentID(args[0])
if err != nil {
return err
}
Expand All @@ -65,10 +65,10 @@ func (c *attachmentsSaveCommand) run(cmd *cobra.Command, args []string) error {
return apierr.ErrNotFound("message", strconv.FormatInt(messageID, 10))
}
attachments := htmlutil.ExtractAttachments(message.Content)
if position > len(attachments) {
attachment, found := findAttachmentByID(messageID, fmt.Sprintf("%d:%s", messageID, selector), attachments)
if !found {
return apierr.ErrNotFound("attachment", args[0])
}
attachment := attachments[position-1]

destination, err := attachmentDestination(c.output, attachment.Filename)
if err != nil {
Expand Down Expand Up @@ -101,17 +101,19 @@ func savedAttachmentForMarkdown(attachment savedAttachment) savedAttachment {
return attachment
}

func parseAttachmentID(id string) (int64, int, error) {
func parseAttachmentID(id string) (int64, string, error) {
parts := strings.Split(id, ":")
if len(parts) != 2 {
return 0, 0, apierr.ErrUsage(fmt.Sprintf("invalid attachment ID: %s", id))
return 0, "", apierr.ErrUsage(fmt.Sprintf("invalid attachment ID: %s", id))
}
messageID, messageErr := strconv.ParseInt(parts[0], 10, 64)
position, positionErr := strconv.Atoi(parts[1])
if messageErr != nil || positionErr != nil || messageID <= 0 || position <= 0 {
return 0, 0, apierr.ErrUsage(fmt.Sprintf("invalid attachment ID: %s", id))
if messageErr != nil || messageID <= 0 || !validAttachmentSelector(parts[1]) {
return 0, "", apierr.ErrUsage(fmt.Sprintf("invalid attachment ID: %s", id))
}
return messageID, position, nil
if position, err := strconv.Atoi(parts[1]); err == nil {
return messageID, strconv.Itoa(position), nil
}
return messageID, parts[1], nil
}

func attachmentDestination(outputPath, filename string) (string, error) {
Expand Down
Loading
Loading