commit b3d987867f0f6a4bee7db7a1b5a896b6b2d6a6b2 Author: DCreason Date: Wed May 13 17:07:11 2026 +0000 Initial MailThisForMe TUI client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7acb2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.repo/ +mailer +mtfm-mailer +*.pdf diff --git a/README.md b/README.md new file mode 100644 index 0000000..c774792 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# MailThisForMe TUI + +A dependency-free Go terminal app for quoting and sending Letter-size PDF documents through the MailThisForMe API. + +## Run + +```sh +go run . +``` + +Or build a reusable binary: + +```sh +go build -o mtfm-mailer . +./mtfm-mailer +``` + +Preselect a document at launch: + +```sh +./mtfm-mailer -f=./file.pdf +``` + +The app reads `MAILTHISFORME_API_KEY` from `.env` or the process environment. Optional environment variables: + +- `MAILTHISFORME_BASE_URL` or `MTFM_BASE_URL`, defaulting to `https://api.mailthisforme.com` +- `MTFM_HTTP_TIMEOUT`, defaulting to `60s` + +## What It Does + +- Selects a local PDF with a navigable terminal file list and estimates its page count. +- Checks selected PDFs for US Letter page dimensions before sending. +- Converts the selected PDF to US Letter with Ghostscript when `gs` is available. +- Gets a quote from `POST /v1/mail/quote`. +- Sends the PDF with recipient fields to `POST /v1/mail/send`. +- Supports default return address, a saved return address id, or direct sender fields. +- Lists saved return addresses from `GET /v1/return-addresses`. +- Checks balance and mail job status. + +MailThisForMe preview constraints still apply: PDFs must be US Letter, 10 MB or smaller, and no more than 10 pages. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..702fe59 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module mailer + +go 1.25 diff --git a/main.go b/main.go new file mode 100644 index 0000000..fbabe3d --- /dev/null +++ b/main.go @@ -0,0 +1,1162 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + defaultBaseURL = "https://api.mailthisforme.com" + maxPDFBytes = 10 * 1024 * 1024 +) + +var errNotPDF = errors.New("file does not start with a PDF header") + +type appState struct { + client *apiClient + reader *bufio.Reader + documentPath string + documentSize int64 + pageCount int + documentIssues []string + recipient Address + sender Address + senderMode senderMode + returnAddressID string + lastRequestKey string + lastMailID string +} + +type senderMode int + +const ( + senderDefault senderMode = iota + senderReturnAddressID + senderDirect +) + +type apiClient struct { + baseURL string + apiKey string + httpClient *http.Client +} + +type Address struct { + Name string `json:"name,omitempty"` + Company string `json:"company,omitempty"` + Address1 string `json:"address1,omitempty"` + Address2 string `json:"address2,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + Country string `json:"country,omitempty"` + Phone string `json:"phone,omitempty"` +} + +type returnAddressList struct { + Data []returnAddress `json:"data"` +} + +type returnAddress struct { + ID string `json:"id"` + Label string `json:"label"` + Name string `json:"name"` + Company string `json:"company"` + Address1 string `json:"address1"` + Address2 string `json:"address2"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` + IsDefault bool `json:"is_default"` +} + +type fileEntry struct { + name string + path string + isDir bool +} + +type pdfAnalysis struct { + PageCount int + Issues []string + Warnings []string + Dimensions []pageDimension +} + +type pageDimension struct { + Page int + Width float64 + Height float64 + IsLetter bool +} + +func main() { + fileFlag := flag.String("f", "", "PDF document to preselect") + flag.Parse() + loadDotEnv(".env") + + apiKey := firstEnv("MAILTHISFORME_API_KEY", "MTFM_API_KEY") + baseURL := firstEnv("MAILTHISFORME_BASE_URL", "MTFM_BASE_URL") + if baseURL == "" { + baseURL = defaultBaseURL + } + + timeout := 60 * time.Second + if raw := os.Getenv("MTFM_HTTP_TIMEOUT"); raw != "" { + parsed, err := time.ParseDuration(raw) + if err == nil && parsed > 0 { + timeout = parsed + } + } + + state := &appState{ + client: &apiClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: timeout, + }, + }, + reader: bufio.NewReader(os.Stdin), + senderMode: senderDefault, + } + + if strings.TrimSpace(*fileFlag) != "" { + message, err := state.setDocumentPath(*fileFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "error: -f: %v\n", err) + os.Exit(1) + } + if message != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", message) + } + } + + if err := state.run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func (a *appState) run() error { + for { + a.drawHome() + choice := a.prompt("Choose an option") + + switch strings.TrimSpace(choice) { + case "1": + a.selectDocument() + case "2": + a.setRecipient() + case "3": + a.setSender() + case "4": + a.quoteDocument() + case "5": + a.sendDocument() + case "6": + a.showBalance() + case "7": + a.checkStatus() + case "c", "C": + a.convertSelectedDocument() + case "8", "q", "Q": + fmt.Println("Goodbye.") + return nil + default: + a.pause("Unknown option.") + } + } +} + +func (a *appState) drawHome() { + clearScreen() + fmt.Println("MailThisForMe TUI") + fmt.Println(strings.Repeat("=", 19)) + fmt.Printf("API: %s\n", a.client.baseURL) + fmt.Printf("API key: %s\n", maskSecret(a.client.apiKey)) + fmt.Printf("PDF: %s\n", a.documentSummary()) + fmt.Printf("Pages: %s\n", pageSummary(a.pageCount)) + if len(a.documentIssues) > 0 { + fmt.Printf("PDF check: %s\n", strings.Join(a.documentIssues, " ")) + } + fmt.Printf("To: %s\n", addressOneLine(a.recipient)) + fmt.Printf("Sender: %s\n", a.senderSummary()) + if a.lastMailID != "" { + fmt.Printf("Last job: %s\n", a.lastMailID) + } + fmt.Println() + fmt.Println("1. Select PDF document") + fmt.Println("2. Enter recipient address") + fmt.Println("3. Choose sender / return address") + fmt.Println("4. Quote selected document") + fmt.Println("5. Send selected document") + fmt.Println("6. Show balance") + fmt.Println("7. Check job status") + fmt.Println("C. Convert selected PDF to US Letter") + fmt.Println("8. Quit") + fmt.Println() +} + +func (a *appState) selectDocument() { + dir, err := os.Getwd() + if err != nil { + a.pause(fmt.Sprintf("Could not read current directory: %v", err)) + return + } + if a.documentPath != "" { + dir = filepath.Dir(a.documentPath) + } + + for { + entries, err := readFileEntries(dir) + if err != nil { + a.pause(fmt.Sprintf("Could not list %s: %v", dir, err)) + return + } + + clearScreen() + fmt.Println("Select PDF Document") + fmt.Println(strings.Repeat("=", 19)) + fmt.Printf("Directory: %s\n\n", dir) + for i, entry := range entries { + label := entry.name + if entry.isDir { + label += string(os.PathSeparator) + } + fmt.Printf("%2d. %s\n", i+1, label) + } + fmt.Println() + fmt.Println("Type a number to open/select, a path to select directly, or blank to cancel.") + + raw := strings.TrimSpace(a.prompt("Selection")) + if raw == "" { + return + } + + var selected string + if index, err := strconv.Atoi(raw); err == nil { + if index < 1 || index > len(entries) { + a.pause("Invalid selection.") + continue + } + entry := entries[index-1] + if entry.isDir { + dir = entry.path + continue + } + selected = entry.path + } else { + selected = expandPath(raw) + if !filepath.IsAbs(selected) { + selected = filepath.Join(dir, selected) + } + if info, err := os.Stat(selected); err == nil && info.IsDir() { + dir = selected + continue + } + } + + message, err := a.setDocumentPath(selected) + if err != nil { + a.pause(fmt.Sprintf("Could not select document: %v", err)) + continue + } + if message != "" { + a.pause(fmt.Sprintf("Selected %s (%s, %s). %s", a.documentPath, humanBytes(a.documentSize), pageSummary(a.pageCount), message)) + } else { + a.pause(fmt.Sprintf("Selected %s (%s, %s).", a.documentPath, humanBytes(a.documentSize), pageSummary(a.pageCount))) + } + return + } +} + +func (a *appState) setDocumentPath(path string) (string, error) { + path = expandPath(path) + absPath, err := filepath.Abs(path) + if err == nil { + path = absPath + } + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("could not read file: %w", err) + } + if info.IsDir() { + return "", errors.New("that path is a directory, not a PDF") + } + if !strings.EqualFold(filepath.Ext(path), ".pdf") { + return "", errors.New("selected file must have a .pdf extension") + } + if info.Size() > maxPDFBytes { + return "", fmt.Errorf("file is %s; MailThisForMe allows PDFs up to 10 MB", humanBytes(info.Size())) + } + pages, err := estimatePDFPageCount(path) + pageWarning := "" + if err != nil { + if errors.Is(err, errNotPDF) { + return "", err + } + pageWarning = fmt.Sprintf("%v.", err) + pages = 0 + } + + analysis, err := analyzePDF(path) + if err != nil { + return "", err + } + + a.documentPath = path + a.documentSize = info.Size() + a.pageCount = pages + if analysis.PageCount > 0 { + a.pageCount = analysis.PageCount + } + a.documentIssues = append([]string{}, analysis.Issues...) + + messages := append([]string{}, analysis.Issues...) + messages = append(messages, analysis.Warnings...) + if pageWarning != "" { + messages = append(messages, pageWarning) + } + return strings.Join(messages, " "), nil +} + +func (a *appState) setRecipient() { + clearScreen() + fmt.Println("Recipient Address") + fmt.Println(strings.Repeat("=", 17)) + addr := a.promptAddress(a.recipient) + a.recipient = addr + a.pause("Recipient updated.") +} + +func (a *appState) setSender() { + for { + clearScreen() + fmt.Println("Sender / Return Address") + fmt.Println(strings.Repeat("=", 23)) + fmt.Println("1. Use account default return address") + fmt.Println("2. Enter return address id") + fmt.Println("3. List saved return addresses") + fmt.Println("4. Enter direct sender address") + fmt.Println("5. Back") + fmt.Println() + choice := a.prompt("Choose an option") + switch strings.TrimSpace(choice) { + case "1": + a.senderMode = senderDefault + a.returnAddressID = "" + a.pause("Sender will use the account default return address.") + return + case "2": + id := strings.TrimSpace(a.prompt("Return address id (raddr_...)")) + if id == "" { + a.pause("No id entered.") + continue + } + a.senderMode = senderReturnAddressID + a.returnAddressID = id + a.pause("Return address id set.") + return + case "3": + a.chooseReturnAddress() + return + case "4": + clearScreen() + fmt.Println("Direct Sender Address") + fmt.Println(strings.Repeat("=", 21)) + a.sender = a.promptAddress(a.sender) + a.senderMode = senderDirect + a.returnAddressID = "" + a.pause("Direct sender address updated.") + return + case "5": + return + default: + a.pause("Unknown option.") + } + } +} + +func (a *appState) chooseReturnAddress() { + if !a.client.hasAPIKey() { + a.pause("API key is required to list return addresses.") + return + } + + var list returnAddressList + if err := a.client.getJSON(context.Background(), "/v1/return-addresses", true, &list); err != nil { + a.pause(fmt.Sprintf("Could not list return addresses: %v", err)) + return + } + if len(list.Data) == 0 { + a.pause("No saved return addresses found.") + return + } + + clearScreen() + fmt.Println("Saved Return Addresses") + fmt.Println(strings.Repeat("=", 22)) + for i, item := range list.Data { + defaultMark := "" + if item.IsDefault { + defaultMark = " [default]" + } + fmt.Printf("%d. %s%s\n", i+1, item.Label, defaultMark) + fmt.Printf(" %s, %s, %s %s %s (%s)\n", item.Name, item.Address1, item.City, item.State, item.PostalCode, item.ID) + } + fmt.Println() + raw := a.prompt("Choose a number, or blank to cancel") + if strings.TrimSpace(raw) == "" { + return + } + index, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || index < 1 || index > len(list.Data) { + a.pause("Invalid selection.") + return + } + a.senderMode = senderReturnAddressID + a.returnAddressID = list.Data[index-1].ID + a.pause(fmt.Sprintf("Using %s.", a.returnAddressID)) +} + +func (a *appState) quoteDocument() { + pageCount := a.pageCount + if pageCount > 0 { + if !confirm(a, "Use estimated "+pageSummary(pageCount)) { + pageCount = 0 + } + } + if pageCount <= 0 { + pageCount = a.promptPageCount() + if pageCount <= 0 { + return + } + } + if pageCount > 10 { + a.pause(fmt.Sprintf("Estimated %d pages. MailThisForMe quotes accept 1-10 pages.", pageCount)) + return + } + + payload := map[string]int{"page_count": pageCount} + var out map[string]any + if err := a.client.postJSON(context.Background(), "/v1/mail/quote", false, payload, &out); err != nil { + a.pause(fmt.Sprintf("Quote failed: %v", err)) + return + } + + clearScreen() + fmt.Println("Quote") + fmt.Println(strings.Repeat("=", 5)) + fmt.Println(formatAPIObject(out)) + a.pause("") +} + +func (a *appState) sendDocument() { + if err := a.validateSendInputs(); err != nil { + a.pause(err.Error()) + return + } + if !a.client.hasAPIKey() { + a.pause("API key is required to send mail.") + return + } + + clearScreen() + fmt.Println("Send Document") + fmt.Println(strings.Repeat("=", 13)) + fmt.Printf("PDF: %s\n", a.documentSummary()) + fmt.Printf("To: %s\n", addressOneLine(a.recipient)) + fmt.Printf("Sender: %s\n", a.senderSummary()) + fmt.Println() + fmt.Println("MailThisForMe preview jobs are queued in test mode and do not physically mail automatically.") + if !confirm(a, "Submit this document?") { + return + } + + requestKey := a.lastRequestKey + if requestKey == "" || !confirm(a, "Reuse previous request key?") { + requestKey = newRequestKey() + } + a.lastRequestKey = requestKey + + var out map[string]any + err := a.client.sendMail(context.Background(), a.documentPath, requestKey, a.recipient, a.senderMode, a.returnAddressID, a.sender, &out) + if err != nil { + a.pause(fmt.Sprintf("Send failed: %v\nRequest key: %s", err, requestKey)) + return + } + + if id, ok := out["id"].(string); ok { + a.lastMailID = id + } + + clearScreen() + fmt.Println("Sent") + fmt.Println(strings.Repeat("=", 4)) + fmt.Printf("Request key: %s\n\n", requestKey) + fmt.Println(formatAPIObject(out)) + a.pause("") +} + +func (a *appState) showBalance() { + if !a.client.hasAPIKey() { + a.pause("API key is required to read balance.") + return + } + + var out map[string]any + if err := a.client.getJSON(context.Background(), "/v1/balance", true, &out); err != nil { + a.pause(fmt.Sprintf("Balance request failed: %v", err)) + return + } + + clearScreen() + fmt.Println("Balance") + fmt.Println(strings.Repeat("=", 7)) + fmt.Println(formatAPIObject(out)) + a.pause("") +} + +func (a *appState) checkStatus() { + if !a.client.hasAPIKey() { + a.pause("API key is required to check status.") + return + } + + defaultID := a.lastMailID + prompt := "Mail job id" + if defaultID != "" { + prompt += " [" + defaultID + "]" + } + id := strings.TrimSpace(a.prompt(prompt)) + if id == "" { + id = defaultID + } + if id == "" { + a.pause("No mail job id entered.") + return + } + + var out map[string]any + if err := a.client.getJSON(context.Background(), "/v1/mail/"+id, true, &out); err != nil { + a.pause(fmt.Sprintf("Status request failed: %v", err)) + return + } + + clearScreen() + fmt.Println("Mail Job Status") + fmt.Println(strings.Repeat("=", 15)) + fmt.Println(formatAPIObject(out)) + a.pause("") +} + +func (a *appState) convertSelectedDocument() { + if a.documentPath == "" { + a.pause("Select a PDF document first.") + return + } + if _, err := exec.LookPath("gs"); err != nil { + a.pause("Ghostscript is not available on PATH. Install gs or export the PDF as US Letter from the source application.") + return + } + + ext := filepath.Ext(a.documentPath) + defaultOutput := strings.TrimSuffix(a.documentPath, ext) + "-letter" + ext + output := a.promptDefault("Output PDF", defaultOutput) + output = expandPath(output) + if !filepath.IsAbs(output) { + output = filepath.Join(filepath.Dir(a.documentPath), output) + } + if output == a.documentPath { + a.pause("Output path must be different from the selected PDF.") + return + } + if _, err := os.Stat(output); err == nil { + if !confirm(a, "Output exists. Overwrite") { + return + } + } else if !errors.Is(err, os.ErrNotExist) { + a.pause(fmt.Sprintf("Could not inspect output path: %v", err)) + return + } + + cmd := exec.Command( + "gs", + "-o", output, + "-sDEVICE=pdfwrite", + "-sPAPERSIZE=letter", + "-dFIXEDMEDIA", + "-dPDFFitPage", + a.documentPath, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + details := strings.TrimSpace(stderr.String()) + if details != "" { + a.pause(fmt.Sprintf("Ghostscript conversion failed: %v\n%s", err, details)) + } else { + a.pause(fmt.Sprintf("Ghostscript conversion failed: %v", err)) + } + return + } + + message, err := a.setDocumentPath(output) + if err != nil { + a.pause(fmt.Sprintf("Created %s, but could not select it: %v", output, err)) + return + } + if message != "" { + a.pause(fmt.Sprintf("Created and selected %s. %s", output, message)) + return + } + a.pause(fmt.Sprintf("Created and selected %s.", output)) +} + +func (a *appState) validateSendInputs() error { + if a.documentPath == "" { + return errors.New("select a PDF document first") + } + if err := validateRequiredAddress(a.recipient, "recipient"); err != nil { + return err + } + if a.senderMode == senderReturnAddressID && strings.TrimSpace(a.returnAddressID) == "" { + return errors.New("enter a return address id or choose the default sender") + } + if a.senderMode == senderDirect { + if err := validateRequiredAddress(a.sender, "sender"); err != nil { + return err + } + } + if a.pageCount > 10 { + return fmt.Errorf("selected PDF is estimated at %d pages; MailThisForMe allows up to 10", a.pageCount) + } + if len(a.documentIssues) > 0 { + return fmt.Errorf("selected PDF failed local preflight: %s", strings.Join(a.documentIssues, " ")) + } + return nil +} + +func (a *appState) promptAddress(current Address) Address { + addr := current + addr.Name = a.promptDefault("Name", addr.Name) + addr.Company = a.promptDefault("Company (optional)", addr.Company) + addr.Address1 = a.promptDefault("Address line 1", addr.Address1) + addr.Address2 = a.promptDefault("Address line 2 (optional)", addr.Address2) + addr.City = a.promptDefault("City", addr.City) + addr.State = strings.ToUpper(a.promptDefault("State", addr.State)) + addr.PostalCode = a.promptDefault("Postal code", addr.PostalCode) + addr.Country = strings.ToUpper(a.promptDefault("Country", defaultString(addr.Country, "US"))) + addr.Phone = a.promptDefault("Phone (optional)", addr.Phone) + return addr +} + +func (a *appState) promptPageCount() int { + for { + raw := strings.TrimSpace(a.prompt("Page count (1-10)")) + if raw == "" { + return 0 + } + n, err := strconv.Atoi(raw) + if err == nil && n >= 1 && n <= 10 { + return n + } + fmt.Println("Enter a number from 1 to 10, or blank to cancel.") + } +} + +func (a *appState) prompt(label string) string { + fmt.Print(label + ": ") + text, _ := a.reader.ReadString('\n') + return strings.TrimRight(text, "\r\n") +} + +func (a *appState) promptDefault(label, current string) string { + if current != "" { + label += " [" + current + "]" + } + value := strings.TrimSpace(a.prompt(label)) + if value == "" { + return current + } + return value +} + +func (a *appState) pause(message string) { + if message != "" { + fmt.Println() + fmt.Println(message) + } + fmt.Println() + fmt.Print("Press Enter to continue...") + _, _ = a.reader.ReadString('\n') +} + +func readFileEntries(dir string) ([]fileEntry, error) { + parent := filepath.Clean(filepath.Join(dir, "..")) + entries := []fileEntry{{ + name: "..", + path: parent, + isDir: true, + }} + + items, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, item := range items { + entries = append(entries, fileEntry{ + name: item.Name(), + path: filepath.Join(dir, item.Name()), + isDir: item.IsDir(), + }) + } + + sort.SliceStable(entries[1:], func(i, j int) bool { + left := entries[i+1] + right := entries[j+1] + if left.isDir != right.isDir { + return left.isDir + } + return strings.ToLower(left.name) < strings.ToLower(right.name) + }) + + return entries, nil +} + +func (a *appState) documentSummary() string { + if a.documentPath == "" { + return "not selected" + } + return fmt.Sprintf("%s (%s)", a.documentPath, humanBytes(a.documentSize)) +} + +func (a *appState) senderSummary() string { + switch a.senderMode { + case senderDefault: + return "account default return address" + case senderReturnAddressID: + return "return address " + a.returnAddressID + case senderDirect: + return addressOneLine(a.sender) + default: + return "not set" + } +} + +func (c *apiClient) hasAPIKey() bool { + return strings.TrimSpace(c.apiKey) != "" +} + +func (c *apiClient) getJSON(ctx context.Context, path string, authenticated bool, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + if authenticated { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return c.do(req, out) +} + +func (c *apiClient) postJSON(ctx context.Context, path string, authenticated bool, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if authenticated { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + return c.do(req, out) +} + +func (c *apiClient) sendMail(ctx context.Context, documentPath, requestKey string, recipient Address, mode senderMode, returnAddressID string, sender Address, out any) error { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + if err := addFilePart(writer, "document", documentPath); err != nil { + return err + } + addAddressFields(writer, "recipient", recipient) + + switch mode { + case senderReturnAddressID: + _ = writer.WriteField("return_address_id", returnAddressID) + case senderDirect: + addAddressFields(writer, "sender", sender) + } + + metadata := `{"source":"mtfm-mailer-tui"}` + _ = writer.WriteField("metadata", metadata) + _ = writer.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/mail/send", &body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-MailThisForMe-Request-Key", requestKey) + return c.do(req, out) +} + +func (c *apiClient) do(req *http.Request, out any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("%s: %s", resp.Status, formatAPIError(data)) + } + if out == nil || len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("could not parse JSON response: %w\n%s", err, string(data)) + } + return nil +} + +func addFilePart(writer *multipart.Writer, field, path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(field), escapeQuotes(filepath.Base(path)))) + header.Set("Content-Type", "application/pdf") + part, err := writer.CreatePart(header) + if err != nil { + return err + } + _, err = io.Copy(part, file) + return err +} + +func addAddressFields(writer *multipart.Writer, prefix string, addr Address) { + fields := map[string]string{ + "name": addr.Name, + "company": addr.Company, + "address1": addr.Address1, + "address2": addr.Address2, + "city": addr.City, + "state": addr.State, + "postal_code": addr.PostalCode, + "country": addr.Country, + "phone": addr.Phone, + } + for key, value := range fields { + value = strings.TrimSpace(value) + if value == "" { + continue + } + _ = writer.WriteField(prefix+"["+key+"]", value) + } +} + +func validateRequiredAddress(addr Address, label string) error { + missing := []string{} + if strings.TrimSpace(addr.Name) == "" { + missing = append(missing, "name") + } + if strings.TrimSpace(addr.Address1) == "" { + missing = append(missing, "address1") + } + if strings.TrimSpace(addr.City) == "" { + missing = append(missing, "city") + } + if strings.TrimSpace(addr.State) == "" { + missing = append(missing, "state") + } + if strings.TrimSpace(addr.PostalCode) == "" { + missing = append(missing, "postal_code") + } + if strings.TrimSpace(addr.Country) == "" { + missing = append(missing, "country") + } + if len(missing) > 0 { + return fmt.Errorf("%s address is missing: %s", label, strings.Join(missing, ", ")) + } + return nil +} + +func estimatePDFPageCount(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + if len(data) < 5 || string(data[:5]) != "%PDF-" { + return 0, errNotPDF + } + + re := regexp.MustCompile(`/Type\s*/Page\b`) + matches := re.FindAll(data, -1) + if len(matches) == 0 { + return 0, errors.New("could not find page objects") + } + if len(matches) > 10 { + return len(matches), fmt.Errorf("estimated %d pages; MailThisForMe allows up to 10", len(matches)) + } + return len(matches), nil +} + +func analyzePDF(path string) (pdfAnalysis, error) { + data, err := os.ReadFile(path) + if err != nil { + return pdfAnalysis{}, err + } + if len(data) < 5 || string(data[:5]) != "%PDF-" { + return pdfAnalysis{}, errNotPDF + } + + analysis := pdfAnalysis{} + pageRe := regexp.MustCompile(`/Type\s*/Page\b`) + analysis.PageCount = len(pageRe.FindAll(data, -1)) + if analysis.PageCount == 0 { + analysis.Warnings = append(analysis.Warnings, "Could not estimate PDF page count.") + } + if analysis.PageCount > 10 { + analysis.Issues = append(analysis.Issues, fmt.Sprintf("PDF has %d pages; MailThisForMe allows up to 10.", analysis.PageCount)) + } + + mediaBoxRe := regexp.MustCompile(`/MediaBox\s*\[\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\]`) + boxes := mediaBoxRe.FindAllSubmatch(data, -1) + if len(boxes) == 0 { + analysis.Warnings = append(analysis.Warnings, "Could not find PDF MediaBox dimensions for local Letter-size validation.") + return analysis, nil + } + + dimensionsToCheck := len(boxes) + if analysis.PageCount > 0 && dimensionsToCheck > analysis.PageCount { + dimensionsToCheck = analysis.PageCount + } + if analysis.PageCount > 0 && len(boxes) < analysis.PageCount && len(boxes) != 1 { + analysis.Warnings = append(analysis.Warnings, fmt.Sprintf("Found %d MediaBox entries for %d pages; local page-size validation may be incomplete.", len(boxes), analysis.PageCount)) + } + if analysis.PageCount > 1 && len(boxes) == 1 { + dimensionsToCheck = analysis.PageCount + } + + for i := 0; i < dimensionsToCheck; i++ { + box := boxes[0] + if len(boxes) > 1 { + box = boxes[i] + } + x1, _ := strconv.ParseFloat(string(box[1]), 64) + y1, _ := strconv.ParseFloat(string(box[2]), 64) + x2, _ := strconv.ParseFloat(string(box[3]), 64) + y2, _ := strconv.ParseFloat(string(box[4]), 64) + width := absFloat(x2 - x1) + height := absFloat(y2 - y1) + dimension := pageDimension{ + Page: i + 1, + Width: width, + Height: height, + IsLetter: isLetterSize(width, height), + } + analysis.Dimensions = append(analysis.Dimensions, dimension) + if !dimension.IsLetter { + analysis.Issues = append(analysis.Issues, fmt.Sprintf("Page %d is %s x %s points; expected US Letter 612 x 792 points.", dimension.Page, formatPoints(width), formatPoints(height))) + } + } + + return analysis, nil +} + +func isLetterSize(width, height float64) bool { + const tolerance = 2.0 + portrait := absFloat(width-612) <= tolerance && absFloat(height-792) <= tolerance + landscape := absFloat(width-792) <= tolerance && absFloat(height-612) <= tolerance + return portrait || landscape +} + +func absFloat(value float64) float64 { + if value < 0 { + return -value + } + return value +} + +func formatPoints(value float64) string { + formatted := strconv.FormatFloat(value, 'f', 2, 64) + formatted = strings.TrimRight(formatted, "0") + return strings.TrimRight(formatted, ".") +} + +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + key, value, _ := strings.Cut(line, "=") + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + if key != "" && os.Getenv(key) == "" { + _ = os.Setenv(key, value) + } + } +} + +func firstEnv(keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func expandPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return path + } + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func formatAPIObject(v any) string { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprint(v) + } + return string(data) +} + +func formatAPIError(data []byte) string { + var decoded any + if err := json.Unmarshal(data, &decoded); err == nil { + return formatAPIObject(decoded) + } + text := strings.TrimSpace(string(data)) + if text == "" { + return "empty response body" + } + return text +} + +func maskSecret(secret string) string { + secret = strings.TrimSpace(secret) + if secret == "" { + return "missing" + } + if len(secret) <= 10 { + return strings.Repeat("*", len(secret)) + } + return secret[:8] + "..." + secret[len(secret)-4:] +} + +func addressOneLine(addr Address) string { + parts := []string{} + for _, part := range []string{addr.Name, addr.Company, addr.Address1, addr.Address2, addr.City, addr.State, addr.PostalCode, addr.Country} { + part = strings.TrimSpace(part) + if part != "" { + parts = append(parts, part) + } + } + if len(parts) == 0 { + return "not set" + } + return strings.Join(parts, ", ") +} + +func pageSummary(pages int) string { + if pages <= 0 { + return "unknown" + } + if pages == 1 { + return "1 page" + } + return fmt.Sprintf("%d pages", pages) +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for size := n / unit; size >= unit; size /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func confirm(a *appState, label string) bool { + answer := strings.ToLower(strings.TrimSpace(a.prompt(label + " [y/N]"))) + return answer == "y" || answer == "yes" +} + +func newRequestKey() string { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return fmt.Sprintf("mtfm-tui-%d", time.Now().UnixNano()) + } + return "mtfm-tui-" + time.Now().UTC().Format("20060102T150405Z") + "-" + hex.EncodeToString(buf[:]) +} + +func escapeQuotes(value string) string { + return strings.ReplaceAll(value, `"`, `\"`) +} + +func clearScreen() { + fmt.Print("\033[H\033[2J") +}